[#30] 코드 구조 개선 (#31)

<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #30

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- ReservationService를 읽기(Find) / 쓰기(Write) 서비스로 분리
- 모든 도메인에 repository를 사용하는 Finder, Writer, Validator 도입 -> ReservationService에 있는 조회, 검증, 쓰기 작업을 별도의 클래스로 분리하기 위함이었고, 이 과정에서 다른 도메인에도 도입함.

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
새로 추가된 기능 & 클래스는 모두 테스트 추가하였고, 작업 후 전체 테스트 완료

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #31
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
이상진 2025-08-06 10:16:08 +00:00 committed by 이상진
parent d3e22888ed
commit 5fe1427fc1
78 changed files with 3962 additions and 1547 deletions

View File

@ -3,64 +3,56 @@ package roomescape.auth.business
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.auth.web.LoginCheckResponse import roomescape.auth.web.LoginCheckResponse
import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequest
import roomescape.auth.web.LoginResponse import roomescape.auth.web.LoginResponse
import roomescape.common.exception.RoomescapeException import roomescape.member.implement.MemberFinder
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Service @Service
class AuthService( class AuthService(
private val memberService: MemberService, private val memberFinder: MemberFinder,
private val jwtHandler: JwtHandler, private val jwtHandler: JwtHandler,
) { ) {
@Transactional(readOnly = true)
fun login(request: LoginRequest): LoginResponse { fun login(request: LoginRequest): LoginResponse {
val params = "email=${request.email}, password=${request.password}" val params = "email=${request.email}, password=${request.password}"
log.debug { "[AuthService.login] 로그인 시작: $params" } log.debug { "[AuthService.login] 시작: $params" }
val member: MemberEntity = fetchMemberOrThrow(AuthErrorCode.LOGIN_FAILED, params, "login") { val member: MemberEntity = fetchOrThrow(AuthErrorCode.LOGIN_FAILED) {
memberService.findByEmailAndPassword(request.email, request.password) memberFinder.findByEmailAndPassword(request.email, request.password)
} }
val accessToken: String = jwtHandler.createToken(member.id!!) val accessToken: String = jwtHandler.createToken(member.id!!)
return LoginResponse(accessToken) return LoginResponse(accessToken)
.also { log.info { "[AuthService.login] 로그인 완료: memberId=${member.id}" } } .also { log.info { "[AuthService.login] 완료: email=${request.email}, memberId=${member.id}" } }
} }
@Transactional(readOnly = true)
fun checkLogin(memberId: Long): LoginCheckResponse { fun checkLogin(memberId: Long): LoginCheckResponse {
log.debug { "[AuthService.checkLogin] 로그인 확인 시작: memberId=$memberId" } log.debug { "[AuthService.checkLogin] 시작: memberId=$memberId" }
val member: MemberEntity =
fetchMemberOrThrow(AuthErrorCode.MEMBER_NOT_FOUND, "memberId=$memberId", "checkLogin") { val member: MemberEntity = fetchOrThrow(AuthErrorCode.MEMBER_NOT_FOUND) { memberFinder.findById(memberId) }
memberService.findById(memberId)
}
return LoginCheckResponse(member.name, member.role.name) return LoginCheckResponse(member.name, member.role.name)
.also { log.info { "[AuthService.checkLogin] 로그인 확인 완료: memberId=$memberId" } } .also { log.info { "[AuthService.checkLogin] 완료: memberId=$memberId, role=${it.role}" } }
}
private fun fetchOrThrow(errorCode: AuthErrorCode, block: () -> MemberEntity): MemberEntity {
try {
return block()
} catch (e: Exception) {
throw AuthException(errorCode, e.message ?: errorCode.message)
}
} }
fun logout(memberId: Long) { fun logout(memberId: Long) {
log.info { "[AuthService.logout] 로그아웃: memberId=$memberId" } log.info { "[AuthService.logout] 로그아웃: memberId=$memberId" }
} }
private fun fetchMemberOrThrow(
errorCode: AuthErrorCode,
params: String,
calledBy: String,
block: () -> MemberEntity,
): MemberEntity {
try {
return block()
} catch (e: Exception) {
if (e !is RoomescapeException) {
log.warn(e) { "[AuthService.$calledBy] 회원 조회 실패: $params" }
}
throw AuthException(errorCode)
}
}
} }

View File

@ -1,5 +1,7 @@
package roomescape.auth.infrastructure.jwt package roomescape.auth.infrastructure.jwt
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Jwts import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys import io.jsonwebtoken.security.Keys
@ -10,6 +12,8 @@ import roomescape.auth.exception.AuthException
import java.util.* import java.util.*
import javax.crypto.SecretKey import javax.crypto.SecretKey
private val log: KLogger = KotlinLogging.logger {}
@Component @Component
class JwtHandler( class JwtHandler(
@Value("\${security.jwt.token.secret-key}") @Value("\${security.jwt.token.secret-key}")
@ -21,6 +25,7 @@ class JwtHandler(
private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray()) private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray())
fun createToken(memberId: Long): String { fun createToken(memberId: Long): String {
log.debug { "[JwtHandler.createToken] 시작: memberId=$memberId" }
val date = Date() val date = Date()
val accessTokenExpiredAt = Date(date.time + (tokenTtlSeconds * 1_000)) val accessTokenExpiredAt = Date(date.time + (tokenTtlSeconds * 1_000))
@ -30,10 +35,12 @@ class JwtHandler(
.expiration(accessTokenExpiredAt) .expiration(accessTokenExpiredAt)
.signWith(secretKey) .signWith(secretKey)
.compact() .compact()
.also { log.debug { "[JwtHandler.createToken] 완료. memberId=$memberId, token=$it" } }
} }
fun getMemberIdFromToken(token: String?): Long { fun getMemberIdFromToken(token: String?): Long {
try { try {
log.debug { "[JwtHandler.getMemberIdFromToken] 시작: token=$token" }
return Jwts.parser() return Jwts.parser()
.verifyWith(secretKey) .verifyWith(secretKey)
.build() .build()
@ -41,6 +48,7 @@ class JwtHandler(
.payload .payload
.get(MEMBER_ID_CLAIM_KEY, Number::class.java) .get(MEMBER_ID_CLAIM_KEY, Number::class.java)
.toLong() .toLong()
.also { log.debug { "[JwtHandler.getMemberIdFromToken] 완료. memberId=$it, token=$token" } }
} catch (_: IllegalArgumentException) { } catch (_: IllegalArgumentException) {
throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND)
} catch (_: ExpiredJwtException) { } catch (_: ExpiredJwtException) {

View File

@ -7,8 +7,8 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import roomescape.auth.docs.AuthAPI
import roomescape.auth.business.AuthService import roomescape.auth.business.AuthService
import roomescape.auth.docs.AuthAPI
import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse

View File

@ -11,14 +11,14 @@ import org.springframework.web.servlet.HandlerInterceptor
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.member.business.MemberService import roomescape.member.implement.MemberFinder
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
private val log: KLogger = KotlinLogging.logger {} private val log: KLogger = KotlinLogging.logger {}
@Component @Component
class AuthInterceptor( class AuthInterceptor(
private val memberService: MemberService, private val memberFinder: MemberFinder,
private val jwtHandler: JwtHandler private val jwtHandler: JwtHandler
) : HandlerInterceptor { ) : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
@ -50,10 +50,10 @@ class AuthInterceptor(
private fun findMember(accessToken: String?): MemberEntity { private fun findMember(accessToken: String?): MemberEntity {
try { try {
val memberId = jwtHandler.getMemberIdFromToken(accessToken) val memberId = jwtHandler.getMemberIdFromToken(accessToken)
return memberService.findById(memberId) return memberFinder.findById(memberId)
.also { MDC.put("member_id", "$memberId") } .also { MDC.put("member_id", "$memberId") }
} catch (e: Exception) { } catch (e: Exception) {
log.info { "[AuthInterceptor] 회원 조회 실패. accessToken = ${accessToken}" } log.info { "[AuthInterceptor] 회원 조회 실패. accessToken = $accessToken" }
val errorCode = AuthErrorCode.MEMBER_NOT_FOUND val errorCode = AuthErrorCode.MEMBER_NOT_FOUND
throw AuthException(errorCode, e.message ?: errorCode.message) throw AuthException(errorCode, e.message ?: errorCode.message)
} }

View File

@ -1,72 +1,44 @@
package roomescape.member.business package roomescape.member.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next import roomescape.member.implement.MemberFinder
import roomescape.member.exception.MemberErrorCode import roomescape.member.implement.MemberWriter
import roomescape.member.exception.MemberException
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.member.infrastructure.persistence.Role import roomescape.member.infrastructure.persistence.Role
import roomescape.member.web.* import roomescape.member.web.*
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@Service @Service
@Transactional(readOnly = true)
class MemberService( class MemberService(
private val tsidFactory: TsidFactory, private val memberWriter: MemberWriter,
private val memberRepository: MemberRepository, private val memberFinder: MemberFinder,
) { ) {
@Transactional(readOnly = true)
fun findMembers(): MemberRetrieveListResponse { fun findMembers(): MemberRetrieveListResponse {
log.debug { "[MemberService.findMembers] 회원 조회 시작" } log.debug { "[MemberService.findMembers] 시작" }
return memberRepository.findAll() return memberFinder.findAll()
.also { log.info { "[MemberService.findMembers] 회원 ${it.size}명 조회 완료" } }
.toRetrieveListResponse() .toRetrieveListResponse()
.also { log.info { "[MemberService.findMembers] 완료. ${it.members.size}명 반환" } }
} }
@Transactional(readOnly = true)
fun findById(memberId: Long): MemberEntity { fun findById(memberId: Long): MemberEntity {
return fetchOrThrow("findById", "memberId=$memberId") { log.debug { "[MemberService.findById] 시작" }
memberRepository.findByIdOrNull(memberId)
}
}
fun findByEmailAndPassword(email: String, password: String): MemberEntity { return memberFinder.findById(memberId)
return fetchOrThrow("findByEmailAndPassword", "email=$email, password=$password") { .also { log.info { "[MemberService.findById] 완료. memberId=${memberId}, email=${it.email}" } }
memberRepository.findByEmailAndPassword(email, password)
}
} }
@Transactional @Transactional
fun createMember(request: SignupRequest): SignupResponse { fun createMember(request: SignupRequest): SignupResponse {
memberRepository.findByEmail(request.email)?.let { log.debug { "[MemberService.createMember] 시작" }
log.info { "[MemberService.createMember] 회원가입 실패(이메일 중복): email=${request.email}" }
throw MemberException(MemberErrorCode.DUPLICATE_EMAIL)
}
val member = MemberEntity( return memberWriter.create(request.name, request.email, request.password, Role.MEMBER)
_id = tsidFactory.next(), .toSignupResponse()
name = request.name, .also { log.info { "[MemberService.create] 완료: email=${request.email} memberId=${it.id}" } }
email = request.email,
password = request.password,
role = Role.MEMBER
)
return memberRepository.save(member).toSignupResponse()
.also { log.info { "[MemberService.create] 회원가입 완료: email=${request.email} memberId=${it.id}" } }
}
private fun fetchOrThrow(calledBy: String, params: String, block: () -> MemberEntity?): MemberEntity {
log.debug { "[MemberService.$calledBy] 회원 조회 시작: $params" }
return block()
?.also { log.info { "[MemberService.$calledBy] 회원 조회 완료: memberId=${it.id}" } }
?: run {
log.info { "[MemberService.$calledBy] 회원 조회 실패: $params" }
throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
}
} }
} }

View File

@ -0,0 +1,47 @@
package roomescape.member.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
import roomescape.member.exception.MemberErrorCode
import roomescape.member.exception.MemberException
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository
private val log: KLogger = KotlinLogging.logger {}
@Component
class MemberFinder(
private val memberRepository: MemberRepository
) {
fun findAll(): List<MemberEntity> {
log.debug { "[MemberFinder.findAll] 회원 조회 시작" }
return memberRepository.findAll()
.also { log.debug { "[MemberFinder.findAll] 회원 ${it.size}명 조회 완료" } }
}
fun findById(id: Long): MemberEntity {
log.debug { "[MemberFinder.findById] 조회 시작: memberId=$id" }
return memberRepository.findByIdOrNull(id)
?.also { log.debug { "[MemberFinder.findById] 조회 완료: memberId=$id, email=${it.email}" } }
?: run {
log.info { "[MemberFinder.findById] 조회 실패: id=$id" }
throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
}
}
fun findByEmailAndPassword(email: String, password: String): MemberEntity {
log.debug { "[MemberFinder.findByEmailAndPassword] 조회 시작: email=$email, password=$password" }
return memberRepository.findByEmailAndPassword(email, password)
?.also { log.debug { "[MemberFinder.findByEmailAndPassword] 조회 완료: email=${email}, memberId=${it.id}" } }
?: run {
log.info { "[MemberFinder.findByEmailAndPassword] 조회 실패: email=${email}, password=${password}" }
throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
}
}
}

View File

@ -0,0 +1,26 @@
package roomescape.member.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.member.exception.MemberErrorCode
import roomescape.member.exception.MemberException
import roomescape.member.infrastructure.persistence.MemberRepository
private val log: KLogger = KotlinLogging.logger {}
@Component
class MemberValidator(
private val memberRepository: MemberRepository
) {
fun validateCanSignup(email: String) {
log.debug { "[MemberValidator.validateCanSignup] 시작: email=$email" }
if (memberRepository.existsByEmail(email)) {
log.info { "[MemberValidator.validateCanSignup] 중복 이메일: email=$email" }
throw MemberException(MemberErrorCode.DUPLICATE_EMAIL)
}
log.debug { "[MemberValidator.validateCanSignup] 완료: email=$email" }
}
}

View File

@ -0,0 +1,35 @@
package roomescape.member.implement
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.member.infrastructure.persistence.Role
private val log: KLogger = KotlinLogging.logger {}
@Component
class MemberWriter(
private val tsidFactory: TsidFactory,
private val memberValidator: MemberValidator,
private val memberRepository: MemberRepository
) {
fun create(name: String, email: String, password: String, role: Role): MemberEntity {
log.debug { "[MemberWriter.create] 시작: email=$email" }
memberValidator.validateCanSignup(email)
val member = MemberEntity(
_id = tsidFactory.next(),
name = name,
email = email,
password = password,
role = role
)
return memberRepository.save(member)
.also { log.debug { "[MemberWriter.create] 완료: email=$email, memberId=${it.id}" } }
}
}

View File

@ -3,7 +3,7 @@ package roomescape.member.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
interface MemberRepository : JpaRepository<MemberEntity, Long> { interface MemberRepository : JpaRepository<MemberEntity, Long> {
fun findByEmailAndPassword(email: String, password: String): MemberEntity? fun existsByEmail(email: String): Boolean
fun findByEmail(email: String): MemberEntity? fun findByEmailAndPassword(email: String, password: String): MemberEntity?
} }

View File

@ -1,17 +1,13 @@
package roomescape.payment.business package roomescape.payment.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next import roomescape.payment.implement.PaymentFinder
import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.implement.PaymentWriter
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.client.PaymentApproveResponse import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentEntity import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.infrastructure.persistence.PaymentRepository
import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse import roomescape.payment.web.PaymentCancelResponse
import roomescape.payment.web.PaymentCreateResponse import roomescape.payment.web.PaymentCreateResponse
@ -23,108 +19,70 @@ private val log = KotlinLogging.logger {}
@Service @Service
class PaymentService( class PaymentService(
private val tsidFactory: TsidFactory, private val paymentFinder: PaymentFinder,
private val paymentRepository: PaymentRepository, private val paymentWriter: PaymentWriter
private val canceledPaymentRepository: CanceledPaymentRepository,
) { ) {
@Transactional @Transactional(readOnly = true)
fun createPayment( fun existsByReservationId(reservationId: Long): Boolean {
approveResponse: PaymentApproveResponse, log.debug { "[PaymentService.existsByReservationId] 시작: reservationId=$reservationId" }
reservation: ReservationEntity,
): PaymentCreateResponse {
log.debug { "[PaymentService.createPayment] 결제 정보 저장 시작: request=$approveResponse, reservationId=${reservation.id}" }
val payment = PaymentEntity(
_id = tsidFactory.next(),
orderId = approveResponse.orderId,
paymentKey = approveResponse.paymentKey,
totalAmount = approveResponse.totalAmount,
reservation = reservation,
approvedAt = approveResponse.approvedAt
)
return paymentRepository.save(payment) return paymentFinder.existsPaymentByReservationId(reservationId)
.toCreateResponse() .also { log.info { "[PaymentService.existsByReservationId] 완료: reservationId=$reservationId, isPaid=$it" } }
.also { log.info { "[PaymentService.createPayment] 결제 정보 저장 완료: paymentId=${it.id}, reservationId=${reservation.id}" } }
} }
@Transactional(readOnly = true) @Transactional
fun isReservationPaid(reservationId: Long): Boolean { fun createPayment(
log.debug { "[PaymentService.isReservationPaid] 예약 결제 여부 확인 시작: reservationId=$reservationId" } approvedPaymentInfo: PaymentApproveResponse,
reservation: ReservationEntity,
): PaymentCreateResponse {
log.debug { "[PaymentService.createPayment] 시작: paymentKey=${approvedPaymentInfo.paymentKey}, reservationId=${reservation.id}" }
return paymentRepository.existsByReservationId(reservationId) val created: PaymentEntity = paymentWriter.create(
.also { log.info { "[PaymentService.isReservationPaid] 예약 결제 여부 확인 완료: reservationId=$reservationId, isPaid=$it" } } paymentKey = approvedPaymentInfo.paymentKey,
orderId = approvedPaymentInfo.orderId,
totalAmount = approvedPaymentInfo.totalAmount,
approvedAt = approvedPaymentInfo.approvedAt,
reservation = reservation
)
return created.toCreateResponse()
.also { log.info { "[PaymentService.createPayment] 완료: paymentKey=${it.paymentKey}, reservationId=${reservation.id}, paymentId=${it.id}" } }
} }
@Transactional @Transactional
fun createCanceledPayment( fun createCanceledPayment(
cancelInfo: PaymentCancelResponse, canceledPaymentInfo: PaymentCancelResponse,
approvedAt: OffsetDateTime, approvedAt: OffsetDateTime,
paymentKey: String, paymentKey: String,
): CanceledPaymentEntity { ): CanceledPaymentEntity {
log.debug { log.debug { "[PaymentService.createCanceledPayment] 시작: paymentKey=$paymentKey" }
"[PaymentService.createCanceledPayment] 결제 취소 정보 저장 시작: paymentKey=$paymentKey" +
", cancelInfo=$cancelInfo" val created: CanceledPaymentEntity = paymentWriter.createCanceled(
} cancelReason = canceledPaymentInfo.cancelReason,
val canceledPayment = CanceledPaymentEntity( cancelAmount = canceledPaymentInfo.cancelAmount,
_id = tsidFactory.next(), canceledAt = canceledPaymentInfo.canceledAt,
paymentKey = paymentKey,
cancelReason = cancelInfo.cancelReason,
cancelAmount = cancelInfo.cancelAmount,
approvedAt = approvedAt, approvedAt = approvedAt,
canceledAt = cancelInfo.canceledAt paymentKey = paymentKey
) )
return canceledPaymentRepository.save(canceledPayment) return created.also {
.also { log.info { "[PaymentService.createCanceledPayment] 완료: paymentKey=${paymentKey}, canceledPaymentId=${it.id}" }
log.info { }
"[PaymentService.createCanceledPayment] 결제 취소 정보 생성 완료: canceledPaymentId=${it.id}" +
", paymentKey=${paymentKey}, amount=${cancelInfo.cancelAmount}, canceledAt=${it.canceledAt}"
}
}
} }
@Transactional @Transactional
fun createCanceledPaymentByReservationId(reservationId: Long): PaymentCancelRequest { fun createCanceledPayment(reservationId: Long): PaymentCancelRequest {
log.debug { "[PaymentService.createCanceledPaymentByReservationId] 예약 삭제 & 결제 취소 정보 저장 시작: reservationId=$reservationId" } log.debug { "[PaymentService.createCanceledPayment] 시작: reservationId=$reservationId" }
val paymentKey: String = paymentRepository.findPaymentKeyByReservationId(reservationId)
?: run {
log.warn { "[PaymentService.createCanceledPaymentByReservationId] 예약 조회 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
val canceled: CanceledPaymentEntity = cancelPayment(paymentKey) val payment: PaymentEntity = paymentFinder.findByReservationId(reservationId)
val canceled: CanceledPaymentEntity = paymentWriter.createCanceled(
return PaymentCancelRequest(paymentKey, canceled.cancelAmount, canceled.cancelReason) payment = payment,
.also { log.info { "[PaymentService.createCanceledPaymentByReservationId] 예약 ID로 결제 취소 완료: reservationId=$reservationId" } } cancelReason = "예약 취소",
} canceledAt = OffsetDateTime.now(),
private fun cancelPayment(
paymentKey: String,
cancelReason: String = "고객 요청",
canceledAt: OffsetDateTime = OffsetDateTime.now(),
): CanceledPaymentEntity {
log.debug { "[PaymentService.cancelPayment] 결제 취소 정보 저장 시작: paymentKey=$paymentKey" }
val payment: PaymentEntity = paymentRepository.findByPaymentKey(paymentKey)
?.also {
paymentRepository.delete(it)
log.info { "[PaymentService.cancelPayment] 결제 정보 삭제 완료: paymentId=${it.id}, paymentKey=$paymentKey" }
}
?: run {
log.warn { "[PaymentService.cancelPayment] 결제 정보 조회 실패: paymentKey=$paymentKey" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
val canceledPayment = CanceledPaymentEntity(
_id = tsidFactory.next(),
paymentKey = paymentKey,
cancelReason = cancelReason,
cancelAmount = payment.totalAmount,
approvedAt = payment.approvedAt,
canceledAt = canceledAt
) )
return canceledPaymentRepository.save(canceledPayment) return PaymentCancelRequest(canceled.paymentKey, canceled.cancelAmount, canceled.cancelReason)
.also { log.info { "[PaymentService.cancelPayment] 결제 취소 정보 저장 완료: canceledPaymentId=${it.id}" } } .also { log.info { "[PaymentService.createCanceledPayment] 완료: reservationId=$reservationId, paymentKey=${it.paymentKey}" } }
} }
@Transactional @Transactional
@ -132,18 +90,10 @@ class PaymentService(
paymentKey: String, paymentKey: String,
canceledAt: OffsetDateTime, canceledAt: OffsetDateTime,
) { ) {
log.debug { "[PaymentService.updateCanceledTime] 취소 시간 업데이트 시작: paymentKey=$paymentKey, canceledAt=$canceledAt" } log.debug { "[PaymentService.updateCanceledTime] 시작: paymentKey=$paymentKey, canceledAt=$canceledAt" }
canceledPaymentRepository.findByPaymentKey(paymentKey)
?.apply { this.canceledAt = canceledAt } paymentFinder.findCanceledByKey(paymentKey).apply { this.canceledAt = canceledAt }
?.also {
log.info { log.info { "[PaymentService.updateCanceledTime] 완료: paymentKey=$paymentKey, canceledAt=$canceledAt" }
"[PaymentService.updateCanceledTime] 취소 시간 업데이트 완료: paymentKey=$paymentKey" +
", canceledAt=$canceledAt"
}
}
?: run {
log.warn { "[PaymentService.updateCanceledTime] 결제 정보 조회 실패: paymentKey=$paymentKey" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
} }
} }

View File

@ -0,0 +1,48 @@
package roomescape.payment.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.infrastructure.persistence.PaymentRepository
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentFinder(
private val paymentRepository: PaymentRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
) {
fun existsPaymentByReservationId(reservationId: Long): Boolean {
log.debug { "[PaymentFinder.existsPaymentByReservationId] 시작: reservationId=$reservationId" }
return paymentRepository.existsByReservationId(reservationId)
.also { log.debug { "[PaymentFinder.existsPaymentByReservationId] 완료: reservationId=$reservationId, isExist=$it" } }
}
fun findByReservationId(reservationId: Long): PaymentEntity {
log.debug { "[PaymentFinder.findByReservationId] 시작: reservationId=$reservationId" }
return paymentRepository.findByReservationId(reservationId)
?.also { log.debug { "[PaymentFinder.findByReservationId] 완료: reservationId=$reservationId" } }
?: run {
log.warn { "[PaymentFinder.findByReservationId] 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
}
fun findCanceledByKey(paymentKey: String): CanceledPaymentEntity {
log.debug { "[PaymentFinder.findCanceledByKey] 시작: paymentKey=$paymentKey" }
return canceledPaymentRepository.findByPaymentKey(paymentKey)
?.also { log.debug { "[PaymentFinder.findCanceledByKey] 완료: canceledPaymentId=${it.id}" } }
?: run {
log.warn { "[PaymentFinder.findCanceledByKey] 실패: paymentKey=$paymentKey" }
throw PaymentException(PaymentErrorCode.CANCELED_PAYMENT_NOT_FOUND)
}
}
}

View File

@ -0,0 +1,81 @@
package roomescape.payment.implement
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.infrastructure.persistence.PaymentRepository
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import java.time.OffsetDateTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentWriter(
private val paymentRepository: PaymentRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
private val tsidFactory: TsidFactory,
) {
fun create(
paymentKey: String,
orderId: String,
totalAmount: Long,
approvedAt: OffsetDateTime,
reservation: ReservationEntity
): PaymentEntity {
log.debug { "[PaymentWriter.create] 시작: paymentKey=${paymentKey}, reservationId=${reservation.id}" }
val payment = PaymentEntity(
_id = tsidFactory.next(),
orderId = orderId,
paymentKey = paymentKey,
totalAmount = totalAmount,
reservation = reservation,
approvedAt = approvedAt
)
return paymentRepository.save(payment)
.also { log.debug { "[PaymentWriter.create] 완료: paymentId=${it.id}, reservationId=${reservation.id}" } }
}
fun createCanceled(
payment: PaymentEntity,
cancelReason: String,
canceledAt: OffsetDateTime,
): CanceledPaymentEntity = createCanceled(
cancelReason = cancelReason,
canceledAt = canceledAt,
cancelAmount = payment.totalAmount,
approvedAt = payment.approvedAt,
paymentKey = payment.paymentKey
)
fun createCanceled(
cancelReason: String,
cancelAmount: Long,
canceledAt: OffsetDateTime,
approvedAt: OffsetDateTime,
paymentKey: String,
): CanceledPaymentEntity {
log.debug { "[PaymentWriter.createCanceled] 시작: paymentKey=$paymentKey cancelAmount=$cancelAmount" }
val canceledPayment = CanceledPaymentEntity(
_id = tsidFactory.next(),
paymentKey = paymentKey,
cancelReason = cancelReason,
cancelAmount = cancelAmount,
approvedAt = approvedAt,
canceledAt = canceledAt
)
return canceledPaymentRepository.save(canceledPayment)
.also {
paymentRepository.deleteByPaymentKey(paymentKey)
log.debug { "[PaymentWriter.createCanceled] 완료: paymentKey=${paymentKey}, canceledPaymentId=${it.id}" }
}
}
}

View File

@ -1,14 +1,12 @@
package roomescape.payment.infrastructure.persistence package roomescape.payment.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface PaymentRepository : JpaRepository<PaymentEntity, Long> { interface PaymentRepository : JpaRepository<PaymentEntity, Long> {
fun existsByReservationId(reservationId: Long): Boolean fun existsByReservationId(reservationId: Long): Boolean
@Query("SELECT p.paymentKey FROM PaymentEntity p WHERE p.reservation.id = :reservationId") fun findByReservationId(reservationId: Long): PaymentEntity?
fun findPaymentKeyByReservationId(reservationId: Long): String?
fun findByPaymentKey(paymentKey: String): PaymentEntity? fun deleteByPaymentKey(paymentKey: String)
} }

View File

@ -3,8 +3,6 @@ package roomescape.payment.web
import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import roomescape.payment.infrastructure.client.PaymentCancelResponseDeserializer import roomescape.payment.infrastructure.client.PaymentCancelResponseDeserializer
import roomescape.payment.infrastructure.persistence.PaymentEntity import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.reservation.web.ReservationRetrieveResponse
import roomescape.reservation.web.toRetrieveResponse
import java.time.OffsetDateTime import java.time.OffsetDateTime
data class PaymentCancelRequest( data class PaymentCancelRequest(
@ -26,15 +24,15 @@ data class PaymentCreateResponse(
val orderId: String, val orderId: String,
val paymentKey: String, val paymentKey: String,
val totalAmount: Long, val totalAmount: Long,
val reservation: ReservationRetrieveResponse, val reservationId: Long,
val approvedAt: OffsetDateTime val approvedAt: OffsetDateTime
) )
fun PaymentEntity.toCreateResponse(): PaymentCreateResponse = PaymentCreateResponse( fun PaymentEntity.toCreateResponse() = PaymentCreateResponse(
id = this.id!!, id = this.id!!,
orderId = this.orderId, orderId = this.orderId,
paymentKey = this.paymentKey, paymentKey = this.paymentKey,
totalAmount = this.totalAmount, totalAmount = this.totalAmount,
reservation = this.reservation.toRetrieveResponse(), reservationId = this.reservation.id!!,
approvedAt = this.approvedAt approvedAt = this.approvedAt
) )

View File

@ -0,0 +1,56 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.MyReservationRetrieveListResponse
import roomescape.reservation.web.ReservationRetrieveListResponse
import roomescape.reservation.web.toRetrieveListResponse
import java.time.LocalDate
private val log = KotlinLogging.logger {}
@Service
@Transactional(readOnly = true)
class ReservationFindService(
private val reservationFinder: ReservationFinder
) {
fun findReservations(): ReservationRetrieveListResponse {
log.debug { "[ReservationService.findReservations] 시작" }
return reservationFinder.findAllByStatuses(*ReservationStatus.confirmedStatus())
.toRetrieveListResponse()
.also { log.info { "[ReservationService.findReservations] ${it.reservations.size}개의 예약 조회 완료" } }
}
fun findAllWaiting(): ReservationRetrieveListResponse {
log.debug { "[ReservationService.findAllWaiting] 시작" }
return reservationFinder.findAllByStatuses(ReservationStatus.WAITING)
.toRetrieveListResponse()
.also { log.info { "[ReservationService.findAllWaiting] ${it.reservations.size}개의 대기 조회 완료" } }
}
fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse {
log.debug { "[ReservationService.findReservationsByMemberId] 시작: memberId=$memberId" }
return reservationFinder.findAllByMemberId(memberId)
.toRetrieveListResponse()
.also { log.info { "[ReservationService.findReservationsByMemberId] ${it.reservations.size}개의 예약 조회 완료: memberId=$memberId" } }
}
fun searchReservations(
themeId: Long?,
memberId: Long?,
startFrom: LocalDate?,
endAt: LocalDate?,
): ReservationRetrieveListResponse {
log.debug { "[ReservationService.searchReservations] 시작: themeId=$themeId, memberId=$memberId, dateFrom=$startFrom, dateTo=$endAt" }
return reservationFinder.searchReservations(themeId, memberId, startFrom, endAt)
.toRetrieveListResponse()
.also { log.info { "[ReservationService.searchReservations] ${it.reservations.size}개의 예약 조회 완료: themeId=$themeId, memberId=$memberId, dateFrom=$startFrom, dateTo=$endAt" } }
}
}

View File

@ -1,322 +0,0 @@
package roomescape.reservation.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next
import roomescape.member.business.MemberService
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
private val log = KotlinLogging.logger {}
@Service
@Transactional
class ReservationService(
private val tsidFactory: TsidFactory,
private val reservationRepository: ReservationRepository,
private val timeService: TimeService,
private val memberService: MemberService,
private val themeService: ThemeService,
) {
@Transactional(readOnly = true)
fun findReservations(): ReservationRetrieveListResponse {
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed()
.build()
val reservations = findAllReservationByStatus(spec)
log.info { "[ReservationService.findReservations] ${reservations.size} 개의 확정 예약 조회 완료" }
return ReservationRetrieveListResponse(reservations)
}
@Transactional(readOnly = true)
fun findAllWaiting(): ReservationRetrieveListResponse {
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.waiting()
.build()
val reservations = findAllReservationByStatus(spec)
log.info { "[ReservationService.findAllWaiting] ${reservations.size} 개의 대기 예약 조회 완료" }
return ReservationRetrieveListResponse(reservations)
}
private fun findAllReservationByStatus(spec: Specification<ReservationEntity>): List<ReservationRetrieveResponse> {
return reservationRepository.findAll(spec).map { it.toRetrieveResponse() }
}
fun deleteReservation(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId, "deleteReservation")
log.debug { "[ReservationService.deleteReservation] 예약 삭제 시작: reservationId=$reservationId, memberId=$memberId" }
reservationRepository.deleteById(reservationId)
log.info { "[ReservationService.deleteReservation] 예약 삭제 완료: reservationId=$reservationId" }
}
fun createConfirmedReservation(
request: ReservationCreateWithPaymentRequest,
memberId: Long,
): ReservationEntity {
val themeId = request.themeId
val timeId = request.timeId
val date: LocalDate = request.date
validateIsReservationExist(themeId, timeId, date, "createConfirmedReservation")
log.debug { "[ReservationService.createConfirmedReservation] 예약 추가 시작: memberId=$memberId, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" }
val reservation: ReservationEntity =
createEntity(timeId, themeId, date, memberId, ReservationStatus.CONFIRMED)
return reservationRepository.save(reservation)
.also { log.info { "[ReservationService.createConfirmedReservation] 예약 추가 완료: reservationId=${it.id}, status=${it.status}" } }
}
fun createReservationByAdmin(request: AdminReservationCreateRequest): ReservationRetrieveResponse {
validateIsReservationExist(request.themeId, request.timeId, request.date)
log.debug { "[ReservationService.createReservationByAdmin] 관리자의 예약 추가: memberId=${request.memberId}, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" }
return addReservationWithoutPayment(
request.themeId,
request.timeId,
request.date,
request.memberId,
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
).also {
log.info { "[ReservationService.createReservationByAdmin] 관리자 예약 추가 완료: reservationId=${it.id}" }
}
}
fun createWaiting(request: WaitingCreateRequest, memberId: Long): ReservationRetrieveResponse {
validateMemberAlreadyReserve(request.themeId, request.timeId, request.date, memberId)
log.debug { "[ReservationService.createWaiting] 예약 대기 추가 시작: memberId=$memberId, themeId=${request.themeId}, timeId=${request.timeId}, date=${request.date}" }
return addReservationWithoutPayment(
request.themeId,
request.timeId,
request.date,
memberId,
ReservationStatus.WAITING
).also {
log.info { "[ReservationService.createWaiting] 예약 대기 추가 완료: reservationId=${it.id}, status=${it.status}" }
}
}
private fun addReservationWithoutPayment(
themeId: Long,
timeId: Long,
date: LocalDate,
memberId: Long,
status: ReservationStatus,
): ReservationRetrieveResponse = createEntity(timeId, themeId, date, memberId, status)
.also {
reservationRepository.save(it)
}.toRetrieveResponse()
private fun validateMemberAlreadyReserve(themeId: Long, timeId: Long, date: LocalDate, memberId: Long) {
log.debug {
"[ReservationService.validateMemberAlreadyReserve] 회원의 중복 예약 여부 확인: themeId=$themeId, timeId=$timeId, date=$date, memberId=$memberId"
}
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.sameMemberId(memberId)
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build()
if (reservationRepository.exists(spec)) {
log.warn { "[ReservationService.validateMemberAlreadyReserve] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" }
throw ReservationException(ReservationErrorCode.ALREADY_RESERVE)
}
}
private fun validateIsReservationExist(
themeId: Long,
timeId: Long,
date: LocalDate,
calledBy: String = "validateIsReservationExist"
) {
log.debug {
"[ReservationService.$calledBy] 예약 존재 여부 확인: themeId=$themeId, timeId=$timeId, date=$date"
}
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed()
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build()
if (reservationRepository.exists(spec)) {
log.warn { "[ReservationService.$calledBy] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" }
throw ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED)
}
}
private fun validateDateAndTime(
requestDate: LocalDate,
requestTime: TimeEntity,
) {
val now = LocalDateTime.now()
val request = LocalDateTime.of(requestDate, requestTime.startAt)
if (request.isBefore(now)) {
log.info { "[ReservationService.validateDateAndTime] 날짜 범위 오류. request=$request, now=$now" }
throw ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME)
}
}
private fun createEntity(
timeId: Long,
themeId: Long,
date: LocalDate,
memberId: Long,
status: ReservationStatus,
): ReservationEntity {
val time: TimeEntity = timeService.findById(timeId)
val theme: ThemeEntity = themeService.findById(themeId)
val member: MemberEntity = memberService.findById(memberId)
validateDateAndTime(date, time)
return ReservationEntity(
_id = tsidFactory.next(),
date = date,
time = time,
theme = theme,
member = member,
status = status
)
}
@Transactional(readOnly = true)
fun searchReservations(
themeId: Long?,
memberId: Long?,
dateFrom: LocalDate?,
dateTo: LocalDate?,
): ReservationRetrieveListResponse {
log.debug { "[ReservationService.searchReservations] 예약 검색 시작: themeId=$themeId, memberId=$memberId, dateFrom=$dateFrom, dateTo=$dateTo" }
validateSearchDateRange(dateFrom, dateTo)
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.confirmed()
.sameThemeId(themeId)
.sameMemberId(memberId)
.dateStartFrom(dateFrom)
.dateEndAt(dateTo)
.build()
val reservations = findAllReservationByStatus(spec)
return ReservationRetrieveListResponse(reservations)
.also { log.info { "[ReservationService.searchReservations] 예약 ${reservations.size}개 조회 완료: themeId=$themeId, memberId=$memberId, dateFrom=$dateFrom, dateTo=$dateTo" } }
}
private fun validateSearchDateRange(startFrom: LocalDate?, endAt: LocalDate?) {
if (startFrom == null || endAt == null) {
return
}
if (startFrom.isAfter(endAt)) {
log.info { "[ReservationService.validateSearchDateRange] 조회 범위 오류: startFrom=$startFrom, endAt=$endAt" }
throw ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE)
}
}
@Transactional(readOnly = true)
fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse {
val reservations = reservationRepository.findAllByMemberId(memberId)
log.info { "[ReservationService.findReservationsByMemberId] memberId=${memberId}${reservations.size}개의 예약 조회 완료" }
return MyReservationRetrieveListResponse(reservations)
}
fun confirmWaiting(reservationId: Long, memberId: Long) {
log.debug { "[ReservationService.confirmWaiting] 대기 예약 승인 시작: reservationId=$reservationId (by adminId=$memberId)" }
validateIsMemberAdmin(memberId, "confirmWaiting")
log.debug { "[ReservationService.confirmWaiting] 대기 여부 확인 시작: reservationId=$reservationId" }
if (reservationRepository.isExistConfirmedReservation(reservationId)) {
log.warn { "[ReservationService.confirmWaiting] 승인 실패(이미 확정된 예약 존재): reservationId=$reservationId" }
throw ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS)
}
log.debug { "[ReservationService.confirmWaiting] 대기 예약 상태 변경 시작: reservationId=$reservationId" }
reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
log.debug { "[ReservationService.confirmWaiting] 대기 예약 상태 변경 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" }
log.info { "[ReservationService.confirmWaiting] 대기 예약 승인 완료: reservationId=$reservationId" }
}
fun deleteWaiting(reservationId: Long, memberId: Long) {
log.debug { "[ReservationService.deleteWaiting] 대기 취소 시작: reservationId=$reservationId, memberId=$memberId" }
val reservation: ReservationEntity = findReservationOrThrow(reservationId, "deleteWaiting")
if (!reservation.isWaiting()) {
log.warn {
"[ReservationService.deleteWaiting] 대기 취소 실패(대기 예약이 아님): reservationId=$reservationId" +
", currentStatus=${reservation.status} memberId=$memberId"
}
throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
}
if (!reservation.isReservedBy(memberId)) {
log.error {
"[ReservationService.deleteWaiting] 대기 취소 실패(예약자 본인의 취소 요청이 아님): reservationId=$reservationId" +
", memberId=$memberId "
}
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
}
log.debug { "[ReservationService.deleteWaiting] 대기 예약 삭제 시작: reservationId=$reservationId" }
reservationRepository.delete(reservation)
log.info { "[ReservationService.deleteWaiting] 대기 취소 완료: reservationId=$reservationId, memberId=$memberId" }
}
fun rejectWaiting(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId, "rejectWaiting")
log.debug { "[ReservationService.rejectWaiting] 대기 예약 삭제 시작: reservationId=$reservationId (by adminId=$memberId)" }
val reservation: ReservationEntity = findReservationOrThrow(reservationId, "rejectWaiting")
if (!reservation.isWaiting()) {
log.warn {
"[ReservationService.rejectWaiting] 대기 예약 삭제 실패(이미 확정 상태): reservationId=$reservationId" +
", status=${reservation.status}"
}
throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
}
reservationRepository.delete(reservation)
log.info { "[ReservationService.rejectWaiting] 대기 예약 삭제 완료: reservationId=$reservationId" }
}
private fun validateIsMemberAdmin(memberId: Long, calledBy: String = "validateIsMemberAdmin") {
log.debug { "[ReservationService.$calledBy] 관리자 여부 확인: memberId=$memberId" }
val member: MemberEntity = memberService.findById(memberId)
if (member.isAdmin()) {
return
}
log.warn { "[ReservationService.$calledBy] 관리자가 아님: memberId=$memberId, role=${member.role}" }
throw ReservationException(ReservationErrorCode.NO_PERMISSION)
}
private fun findReservationOrThrow(
reservationId: Long,
calledBy: String = "findReservationOrThrow"
): ReservationEntity {
log.debug { "[ReservationService.$calledBy] 예약 조회: reservationId=$reservationId" }
return reservationRepository.findByIdOrNull(reservationId)
?.also { log.info { "[ReservationService.$calledBy] 예약 조회 완료: reservationId=$reservationId" } }
?: run {
log.warn { "[ReservationService.$calledBy] 예약 조회 실패: reservationId=$reservationId" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
}
}

View File

@ -8,8 +8,9 @@ import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse import roomescape.payment.web.PaymentCancelResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.web.ReservationCreateResponse
import roomescape.reservation.web.ReservationCreateWithPaymentRequest import roomescape.reservation.web.ReservationCreateWithPaymentRequest
import roomescape.reservation.web.ReservationRetrieveResponse import roomescape.reservation.web.toCreateResponse
import java.time.OffsetDateTime import java.time.OffsetDateTime
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@ -17,47 +18,52 @@ private val log = KotlinLogging.logger {}
@Service @Service
@Transactional @Transactional
class ReservationWithPaymentService( class ReservationWithPaymentService(
private val reservationService: ReservationService, private val reservationWriteService: ReservationWriteService,
private val paymentService: PaymentService, private val paymentService: PaymentService,
) { ) {
fun createReservationAndPayment( fun createReservationAndPayment(
request: ReservationCreateWithPaymentRequest, request: ReservationCreateWithPaymentRequest,
paymentInfo: PaymentApproveResponse, approvedPaymentInfo: PaymentApproveResponse,
memberId: Long, memberId: Long,
): ReservationRetrieveResponse { ): ReservationCreateResponse {
log.info { "[ReservationWithPaymentService.createReservationAndPayment] 예약 & 결제 정보 저장 시작: memberId=$memberId, paymentInfo=$paymentInfo" } log.info { "[ReservationWithPaymentService.createReservationAndPayment] 시작: memberId=$memberId, paymentInfo=$approvedPaymentInfo" }
val reservation: ReservationEntity = reservationService.createConfirmedReservation(request, memberId)
return paymentService.createPayment(paymentInfo, reservation) val reservation: ReservationEntity = reservationWriteService.createReservationWithPayment(request, memberId)
.also { log.info { "[ReservationWithPaymentService.createReservationAndPayment] 예약 & 결제 정보 저장 완료: reservationId=${reservation.id}, paymentId=${it.id}" } } .also { paymentService.createPayment(approvedPaymentInfo, it) }
.reservation
return reservation.toCreateResponse()
.also { log.info { "[ReservationWithPaymentService.createReservationAndPayment] 완료: reservationId=${reservation.id}, paymentId=${it.id}" } }
} }
fun createCanceledPayment( fun createCanceledPayment(
cancelInfo: PaymentCancelResponse, canceledPaymentInfo: PaymentCancelResponse,
approvedAt: OffsetDateTime, approvedAt: OffsetDateTime,
paymentKey: String, paymentKey: String,
) { ) {
paymentService.createCanceledPayment(cancelInfo, approvedAt, paymentKey) paymentService.createCanceledPayment(canceledPaymentInfo, approvedAt, paymentKey)
} }
fun deleteReservationAndPayment( fun deleteReservationAndPayment(
reservationId: Long, reservationId: Long,
memberId: Long, memberId: Long,
): PaymentCancelRequest { ): PaymentCancelRequest {
log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 결제 취소 정보 저장 & 예약 삭제 시작: reservationId=$reservationId" } log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 시작: reservationId=$reservationId" }
val paymentCancelRequest = paymentService.createCanceledPaymentByReservationId(reservationId) val paymentCancelRequest = paymentService.createCanceledPayment(reservationId)
reservationService.deleteReservation(reservationId, memberId) reservationWriteService.deleteReservation(reservationId, memberId)
log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 결제 취소 정보 저장 & 예약 삭제 완료: reservationId=$reservationId" } log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 완료: reservationId=$reservationId" }
return paymentCancelRequest return paymentCancelRequest
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun isNotPaidReservation(reservationId: Long): Boolean { fun isNotPaidReservation(reservationId: Long): Boolean {
log.debug { "[ReservationWithPaymentService.isNotPaidReservation] 예약 결제 여부 확인: reservationId=$reservationId" } log.info { "[ReservationWithPaymentService.isNotPaidReservation] 시작: reservationId=$reservationId" }
return !paymentService.isReservationPaid(reservationId)
.also { log.info { "[ReservationWithPaymentService.isNotPaidReservation] 결제 여부 확인 완료: reservationId=$reservationId, 결제 여부=${!it}" } } val notPaid: Boolean = !paymentService.existsByReservationId(reservationId)
return notPaid.also {
log.info { "[ReservationWithPaymentService.isNotPaidReservation] 완료: reservationId=$reservationId, isPaid=${notPaid}" }
}
} }
fun updateCanceledTime( fun updateCanceledTime(

View File

@ -0,0 +1,104 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.implement.ReservationWriter
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.*
private val log: KLogger = KotlinLogging.logger {}
@Service
@Transactional
class ReservationWriteService(
private val reservationFinder: ReservationFinder,
private val reservationWriter: ReservationWriter
) {
fun createReservationWithPayment(
request: ReservationCreateWithPaymentRequest,
memberId: Long
): ReservationEntity {
log.debug { "[ReservationCommandService.createReservationByAdmin] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${memberId}" }
val created: ReservationEntity = reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.CONFIRMED,
memberId = memberId,
requesterId = memberId
)
return created.also {
log.info { "[ReservationCommandService.createReservationByAdmin] 완료: reservationId=${it.id}" }
}
}
fun createReservationByAdmin(
request: AdminReservationCreateRequest,
memberId: Long
): ReservationCreateResponse {
log.debug { "[ReservationCommandService.createReservationByAdmin] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${request.memberId} by adminId=${memberId}" }
val created: ReservationEntity = reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED,
memberId = request.memberId,
requesterId = memberId
)
return created.toCreateResponse()
.also {
log.info { "[ReservationCommandService.createReservationByAdmin] 관리자 예약 추가 완료: reservationId=${it.id}" }
}
}
fun createWaiting(request: WaitingCreateRequest, memberId: Long): ReservationCreateResponse {
log.debug { "[ReservationCommandService.createWaiting] 시작: date=${request.date}, timeId=${request.timeId}, themeId=${request.themeId}, memberId=${memberId}" }
val created: ReservationEntity = reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.WAITING,
memberId = memberId,
requesterId = memberId
)
return created.toCreateResponse()
.also {
log.info { "[ReservationCommandService.createWaiting] 완료: reservationId=${it.id}" }
}
}
fun deleteReservation(reservationId: Long, memberId: Long) {
log.debug { "[ReservationCommandService.deleteReservation] 시작: reservationId=${reservationId}, memberId=$memberId" }
val reservation: ReservationEntity = reservationFinder.findById(reservationId)
reservationWriter.deleteConfirmed(reservation, requesterId = memberId)
.also { log.info { "[ReservationCommandService.deleteReservation] 완료: reservationId=${reservationId}" } }
}
fun confirmWaiting(reservationId: Long, memberId: Long) {
log.debug { "[ReservationCommandService.confirmWaiting] 시작: reservationId=$reservationId (by adminId=$memberId)" }
reservationWriter.confirm(reservationId)
.also { log.info { "[ReservationCommandService.confirmWaiting] 완료: reservationId=$reservationId" } }
}
fun deleteWaiting(reservationId: Long, memberId: Long) {
log.debug { "[ReservationCommandService.deleteWaiting] 시작: reservationId=$reservationId (by adminId=$memberId)" }
val reservation: ReservationEntity = reservationFinder.findById(reservationId)
reservationWriter.deleteWaiting(reservation, requesterId = memberId)
.also { log.info { "[ReservationCommandService.deleteWaiting] 완료: reservationId=$reservationId" } }
}
}

View File

@ -74,7 +74,7 @@ interface ReservationAPI {
fun createReservationWithPayment( fun createReservationWithPayment(
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest, @Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest,
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> ): ResponseEntity<CommonApiResponse<ReservationCreateResponse>>
@Admin @Admin
@Operation(summary = "관리자 예약 추가", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "관리자 예약 추가", tags = ["관리자 로그인이 필요한 API"])
@ -92,7 +92,8 @@ interface ReservationAPI {
) )
fun createReservationByAdmin( fun createReservationByAdmin(
@Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest, @Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest,
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationCreateResponse>>
@Admin @Admin
@Operation(summary = "모든 예약 대기 조회", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "모든 예약 대기 조회", tags = ["관리자 로그인이 필요한 API"])
@ -116,7 +117,7 @@ interface ReservationAPI {
fun createWaiting( fun createWaiting(
@Valid @RequestBody waitingCreateRequest: WaitingCreateRequest, @Valid @RequestBody waitingCreateRequest: WaitingCreateRequest,
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> ): ResponseEntity<CommonApiResponse<ReservationCreateResponse>>
@LoginRequired @LoginRequired
@Operation(summary = "예약 대기 취소", tags = ["로그인이 필요한 API"]) @Operation(summary = "예약 대기 취소", tags = ["로그인이 필요한 API"])

View File

@ -0,0 +1,94 @@
package roomescape.reservation.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
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.MyReservationRetrieveResponse
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {}
@Component
class ReservationFinder(
private val reservationRepository: ReservationRepository,
private val reservationValidator: ReservationValidator,
) {
fun findById(id: Long): ReservationEntity {
log.debug { "[ReservationFinder.findById] 시작: id=$id" }
return reservationRepository.findByIdOrNull(id)
?.also { log.debug { "[ReservationFinder.findById] 완료: reservationId=$id, date:${it.date}, timeId:${it.time.id}, themeId:${it.theme.id}" } }
?: run {
log.warn { "[ReservationFinder.findById] 조회 실패: reservationId=$id" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
}
fun findAllByStatuses(vararg statuses: ReservationStatus): List<ReservationEntity> {
log.debug { "[ReservationFinder.findAll] 시작: status=${statuses}" }
val spec = ReservationSearchSpecification()
.status(*statuses)
.build()
return reservationRepository.findAll(spec)
.also { log.debug { "[ReservationFinder.findAll] ${it.size}개 예약 조회 완료: status=${statuses}" } }
}
fun findAllByDateAndTheme(
date: LocalDate, theme: ThemeEntity
): List<ReservationEntity> {
log.debug { "[ReservationFinder.findAllByDateAndTheme] 시작: date=$date, themeId=${theme.id}" }
return reservationRepository.findAllByDateAndTheme(date, theme)
.also { log.debug { "[ReservationFinder.findAllByDateAndTheme] ${it.size}개 조회 완료: date=$date, themeId=${theme.id}" } }
}
fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse> {
log.debug { "[ReservationFinder.findAllByMemberId] 시작: memberId=${memberId}" }
return reservationRepository.findAllByMemberId(memberId)
.also { log.debug { "[ReservationFinder.findAllByMemberId] ${it.size}개 예약(대기) 조회 완료: memberId=${memberId}" } }
}
fun searchReservations(
themeId: Long?,
memberId: Long?,
startFrom: LocalDate?,
endAt: LocalDate?,
): List<ReservationEntity> {
reservationValidator.validateSearchDateRange(startFrom, endAt)
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.sameThemeId(themeId)
.sameMemberId(memberId)
.dateStartFrom(startFrom)
.dateEndAt(endAt)
.status(ReservationStatus.CONFIRMED, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
.build()
return reservationRepository.findAll(spec)
.also {
log.debug { "[ReservationFinder.searchReservations] ${it.size}개 예약 조회 완료. " +
"themeId=${themeId}, memberId=${memberId}, startFrom=${startFrom}, endAt=${endAt}" }
}
}
fun isTimeReserved(time: TimeEntity): Boolean {
log.debug { "[ReservationFinder.isTimeReserved] 시작: timeId=${time.id}, startAt=${time.startAt}" }
return reservationRepository.existsByTime(time)
.also { log.debug { "[ReservationFinder.isTimeReserved] 완료: isExist=$it, timeId=${time.id}, startAt=${time.startAt}" } }
}
}

View File

@ -0,0 +1,144 @@
package roomescape.reservation.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.jpa.domain.Specification
import org.springframework.stereotype.Component
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.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class ReservationValidator(
private val reservationRepository: ReservationRepository,
) {
fun validateIsPast(
requestDate: LocalDate,
requestTime: LocalTime,
) {
val now = LocalDateTime.now()
val requestDateTime = LocalDateTime.of(requestDate, requestTime)
log.debug { "[ReservationValidator.validateIsPast] 시작. request=$requestDateTime, now=$now" }
if (requestDateTime.isBefore(now)) {
log.info { "[ReservationValidator.validateIsPast] 날짜 범위 오류. request=$requestDateTime, now=$now" }
throw ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME)
}
log.debug { "[ReservationValidator.validateIsPast] 완료. request=$requestDateTime, now=$now" }
}
fun validateSearchDateRange(startFrom: LocalDate?, endAt: LocalDate?) {
log.debug { "[ReservationValidator.validateSearchDateRange] 시작: startFrom=$startFrom, endAt=$endAt" }
if (startFrom == null || endAt == null) {
log.debug { "[ReservationValidator.validateSearchDateRange] 완료: startFrom=$startFrom, endAt=$endAt" }
return
}
if (startFrom.isAfter(endAt)) {
log.info { "[ReservationValidator.validateSearchDateRange] 날짜 범위 오류: startFrom=$startFrom, endAt=$endAt" }
throw ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE)
}
log.debug { "[ReservationValidator.validateSearchDateRange] 완료: startFrom=$startFrom, endAt=$endAt" }
}
fun validateIsAlreadyExists(date: LocalDate, time: TimeEntity, theme: ThemeEntity) {
val themeId = theme.id
val timeId = time.id
log.debug { "[ReservationValidator.validateIsAlreadyExists] 시작: date=$date, timeId=$timeId, themeId=$themeId" }
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build()
if (reservationRepository.exists(spec)) {
log.warn { "[ReservationValidator.validateIsAlreadyExists] 중복된 예약 존재: date=$date, timeId=$timeId, themeId=$themeId" }
throw ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED)
}
log.debug { "[ReservationValidator.validateIsAlreadyExists] 완료: date=$date, timeId=$timeId, themeId=$themeId" }
}
fun validateMemberAlreadyReserve(themeId: Long, timeId: Long, date: LocalDate, requesterId: Long) {
log.debug { "[ReservationValidator.validateMemberAlreadyReserve] 시작: themeId=$themeId, timeId=$timeId, date=$date, requesterId=$requesterId" }
val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.sameMemberId(requesterId)
.sameThemeId(themeId)
.sameTimeId(timeId)
.sameDate(date)
.build()
if (reservationRepository.exists(spec)) {
log.warn { "[ReservationValidator.validateMemberAlreadyReserve] 중복된 예약 존재: themeId=$themeId, timeId=$timeId, date=$date" }
throw ReservationException(ReservationErrorCode.ALREADY_RESERVE)
}
log.debug { "[ReservationValidator.validateMemberAlreadyReserve] 완료: themeId=$themeId, timeId=$timeId, date=$date, requesterId=$requesterId" }
}
fun validateIsWaiting(reservation: ReservationEntity) {
log.debug { "[ReservationValidator.validateIsWaiting] 시작: reservationId=${reservation.id}, status=${reservation.status}" }
if (!reservation.isWaiting()) {
log.warn { "[ReservationValidator.validateIsWaiting] 대기 상태가 아님: reservationId=${reservation.id}, status=${reservation.status}" }
throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
}
log.debug { "[ReservationValidator.validateIsWaiting] 완료: reservationId=${reservation.id}, status=${reservation.status}" }
}
fun validateCreateAuthority(requester: MemberEntity) {
log.debug { "[ReservationValidator.validateCreateAuthority] 시작: requesterId=${requester.id}" }
if (!requester.isAdmin()) {
log.error { "[ReservationValidator.validateCreateAuthority] 관리자가 아닌 다른 회원의 예약 시도: requesterId=${requester.id}" }
throw ReservationException(ReservationErrorCode.NO_PERMISSION)
}
log.debug { "[ReservationValidator.validateCreateAuthority] 완료: requesterId=${requester.id}" }
}
fun validateDeleteAuthority(reservation: ReservationEntity, requester: MemberEntity) {
val requesterId: Long = requester.id!!
log.debug { "[ReservationValidator.validateDeleteAuthority] 시작: reservationId=${reservation.id}, requesterId=${requesterId}" }
if (requester.isAdmin()) {
log.debug { "[ReservationValidator.validateDeleteAuthority] 완료: reservationId=${reservation.id} requesterId=${requesterId}(Admin)" }
return
}
if (!reservation.isReservedBy(requesterId)) {
log.error {
"[ReservationValidator.validateDeleteAuthority] 예약자 본인이 아님: reservationId=${reservation.id}" +
", memberId=${reservation.member.id} requesterId=${requesterId} "
}
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
}
log.debug { "[ReservationValidator.validateDeleteAuthority] 완료: reservationId=${reservation.id}, requesterId=$requesterId" }
}
fun validateAlreadyConfirmed(reservationId: Long) {
log.debug { "[ReservationValidator.validateAlreadyConfirmed] 시작: reservationId=$reservationId" }
if (reservationRepository.isExistConfirmedReservation(reservationId)) {
log.warn { "[ReservationWriter.confirm] 이미 확정된 예약: reservationId=$reservationId" }
throw ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS)
}
log.debug { "[ReservationValidator.validateAlreadyConfirmed] 완료: reservationId=$reservationId" }
}
}

View File

@ -0,0 +1,104 @@
package roomescape.reservation.implement
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.member.implement.MemberFinder
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.ReservationStatus
import roomescape.theme.implement.ThemeFinder
import roomescape.time.implement.TimeFinder
import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {}
@Component
class ReservationWriter(
private val reservationValidator: ReservationValidator,
private val reservationRepository: ReservationRepository,
private val memberFinder: MemberFinder,
private val timeFinder: TimeFinder,
private val themeFinder: ThemeFinder,
private val tsidFactory: TsidFactory,
) {
fun create(
date: LocalDate,
timeId: Long,
themeId: Long,
memberId: Long,
status: ReservationStatus,
requesterId: Long
): ReservationEntity {
log.debug {
"[ReservationWriter.create] 시작: " +
"date=${date}, timeId=${timeId}, themeId=${themeId}, memberId=${memberId}, status=${status}"
}
val time = timeFinder.findById(timeId).also {
reservationValidator.validateIsPast(date, it.startAt)
}
val theme = themeFinder.findById(themeId)
val member = memberFinder.findById(memberId).also {
if (status == ReservationStatus.WAITING) {
reservationValidator.validateMemberAlreadyReserve(themeId, timeId, date, it.id!!)
} else {
reservationValidator.validateIsAlreadyExists(date, time, theme)
}
if (memberId != requesterId) {
val requester = memberFinder.findById(requesterId)
reservationValidator.validateCreateAuthority(requester)
}
}
val reservation = ReservationEntity(
_id = tsidFactory.next(),
date = date,
time = time,
theme = theme,
member = member,
status = status
)
return reservationRepository.save(reservation)
.also { log.debug { "[ReservationWriter.create] 완료: reservationId=${it.id}, status=${it.status}" } }
}
fun deleteWaiting(reservation: ReservationEntity, requesterId: Long) {
log.debug { "[ReservationWriter.deleteWaiting] 시작: reservationId=${reservation.id}, requesterId=${requesterId}" }
reservationValidator.validateIsWaiting(reservation)
delete(reservation, requesterId)
.also { log.debug { "[ReservationWriter.deleteWaiting] 완료: reservationId=${reservation.id}, status=${reservation.status}" } }
}
fun deleteConfirmed(reservation: ReservationEntity, requesterId: Long) {
log.debug { "[ReservationWriter.deleteConfirmed] 시작: reservationId=${reservation.id}, requesterId=${requesterId}" }
delete(reservation, requesterId)
.also { log.debug { "[ReservationWriter.deleteConfirmed] 완료: reservationId=${reservation.id}, status=${reservation.status}" } }
}
private fun delete(reservation: ReservationEntity, requesterId: Long) {
memberFinder.findById(requesterId)
.also { reservationValidator.validateDeleteAuthority(reservation, requester = it) }
reservationRepository.delete(reservation)
}
fun confirm(reservationId: Long) {
log.debug { "[ReservationWriter.confirm] 대기 여부 확인 시작: reservationId=$reservationId" }
reservationValidator.validateAlreadyConfirmed(reservationId)
reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
log.debug { "[ReservationWriter.confirm] 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" }
}
}

View File

@ -48,5 +48,10 @@ class ReservationEntity(
enum class ReservationStatus { enum class ReservationStatus {
CONFIRMED, CONFIRMED,
CONFIRMED_PAYMENT_REQUIRED, CONFIRMED_PAYMENT_REQUIRED,
WAITING WAITING,
;
companion object {
fun confirmedStatus(): Array<ReservationStatus> = arrayOf(CONFIRMED, CONFIRMED_PAYMENT_REQUIRED)
}
} }

View File

@ -1,17 +1,20 @@
package roomescape.reservation.infrastructure.persistence package roomescape.reservation.infrastructure.persistence
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.JpaSpecificationExecutor
import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import roomescape.reservation.web.MyReservationRetrieveResponse import roomescape.reservation.web.MyReservationRetrieveResponse
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate import java.time.LocalDate
interface ReservationRepository interface ReservationRepository
: JpaRepository<ReservationEntity, Long>, JpaSpecificationExecutor<ReservationEntity> { : JpaRepository<ReservationEntity, Long>, JpaSpecificationExecutor<ReservationEntity> {
fun findAllByTime(time: TimeEntity): List<ReservationEntity> fun findAllByTime(time: TimeEntity): List<ReservationEntity>
fun existsByTime(time: TimeEntity): Boolean
fun findByDateAndThemeId(date: LocalDate, themeId: Long): List<ReservationEntity> fun findByDateAndThemeId(date: LocalDate, themeId: Long): List<ReservationEntity>
@ -20,11 +23,11 @@ interface ReservationRepository
""" """
UPDATE ReservationEntity r UPDATE ReservationEntity r
SET r.status = :status SET r.status = :status
WHERE r.id = :id WHERE r._id = :_id
""" """
) )
fun updateStatusByReservationId( fun updateStatusByReservationId(
@Param(value = "id") reservationId: Long, @Param(value = "_id") reservationId: Long,
@Param(value = "status") statusForChange: ReservationStatus @Param(value = "status") statusForChange: ReservationStatus
): Int ): Int
@ -33,28 +36,28 @@ interface ReservationRepository
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 SELECT 1
FROM ReservationEntity r2 FROM ReservationEntity r2
WHERE r2.id = :id WHERE r2._id = :_id
AND EXISTS ( AND EXISTS (
SELECT 1 FROM ReservationEntity r SELECT 1 FROM ReservationEntity r
WHERE r.theme.id = r2.theme.id WHERE r.theme._id = r2.theme._id
AND r.time.id = r2.time.id AND r.time._id = r2.time._id
AND r.date = r2.date AND r.date = r2.date
AND r.status != 'WAITING' AND r.status != 'WAITING'
) )
) )
""" """
) )
fun isExistConfirmedReservation(@Param("id") reservationId: Long): Boolean fun isExistConfirmedReservation(@Param("_id") reservationId: Long): Boolean
@Query( @Query(
""" """
SELECT new roomescape.reservation.web.MyReservationRetrieveResponse( SELECT new roomescape.reservation.web.MyReservationRetrieveResponse(
r.id, r._id,
t.name, t.name,
r.date, r.date,
r.time.startAt, r.time.startAt,
r.status, r.status,
(SELECT COUNT (r2) * 1L FROM ReservationEntity r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.time = r.time AND r2.id < r.id), (SELECT COUNT (r2) * 1L FROM ReservationEntity r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.time = r.time AND r2._id < r._id),
p.paymentKey, p.paymentKey,
p.totalAmount p.totalAmount
) )
@ -62,8 +65,9 @@ interface ReservationRepository
JOIN r.theme t JOIN r.theme t
LEFT JOIN PaymentEntity p LEFT JOIN PaymentEntity p
ON p.reservation = r ON p.reservation = r
WHERE r.member.id = :memberId WHERE r.member._id = :memberId
""" """
) )
fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse> fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse>
fun findAllByDateAndTheme(date: LocalDate, theme: ThemeEntity): List<ReservationEntity>
} }

View File

@ -33,6 +33,10 @@ class ReservationSearchSpecification(
} }
}) })
fun status(vararg statuses: ReservationStatus) = andIfNotNull { root, _, cb ->
root.get<ReservationStatus>("status").`in`(statuses.toList())
}
fun confirmed(): ReservationSearchSpecification = andIfNotNull { root, _, cb -> fun confirmed(): ReservationSearchSpecification = andIfNotNull { root, _, cb ->
cb.or( cb.or(
cb.equal( cb.equal(

View File

@ -10,7 +10,8 @@ import roomescape.payment.infrastructure.client.PaymentApproveRequest
import roomescape.payment.infrastructure.client.PaymentApproveResponse import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.infrastructure.client.TossPaymentClient import roomescape.payment.infrastructure.client.TossPaymentClient
import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelRequest
import roomescape.reservation.business.ReservationService import roomescape.reservation.business.ReservationWriteService
import roomescape.reservation.business.ReservationFindService
import roomescape.reservation.business.ReservationWithPaymentService import roomescape.reservation.business.ReservationWithPaymentService
import roomescape.reservation.docs.ReservationAPI import roomescape.reservation.docs.ReservationAPI
import java.net.URI import java.net.URI
@ -19,12 +20,13 @@ import java.time.LocalDate
@RestController @RestController
class ReservationController( class ReservationController(
private val reservationWithPaymentService: ReservationWithPaymentService, private val reservationWithPaymentService: ReservationWithPaymentService,
private val reservationService: ReservationService, private val reservationFindService: ReservationFindService,
private val reservationWriteService: ReservationWriteService,
private val paymentClient: TossPaymentClient private val paymentClient: TossPaymentClient
) : ReservationAPI { ) : ReservationAPI {
@GetMapping("/reservations") @GetMapping("/reservations")
override fun findReservations(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> { override fun findReservations(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
val response: ReservationRetrieveListResponse = reservationService.findReservations() val response: ReservationRetrieveListResponse = reservationFindService.findReservations()
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@ -33,7 +35,7 @@ class ReservationController(
override fun findReservationsByMemberId( override fun findReservationsByMemberId(
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>> { ): ResponseEntity<CommonApiResponse<MyReservationRetrieveListResponse>> {
val response: MyReservationRetrieveListResponse = reservationService.findReservationsByMemberId(memberId) val response: MyReservationRetrieveListResponse = reservationFindService.findReservationsByMemberId(memberId)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@ -45,7 +47,7 @@ class ReservationController(
@RequestParam(required = false) dateFrom: LocalDate?, @RequestParam(required = false) dateFrom: LocalDate?,
@RequestParam(required = false) dateTo: LocalDate? @RequestParam(required = false) dateTo: LocalDate?
): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> { ): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
val response: ReservationRetrieveListResponse = reservationService.searchReservations( val response: ReservationRetrieveListResponse = reservationFindService.searchReservations(
themeId, themeId,
memberId, memberId,
dateFrom, dateFrom,
@ -61,7 +63,7 @@ class ReservationController(
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
if (reservationWithPaymentService.isNotPaidReservation(reservationId)) { if (reservationWithPaymentService.isNotPaidReservation(reservationId)) {
reservationService.deleteReservation(reservationId, memberId) reservationWriteService.deleteReservation(reservationId, memberId)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@ -79,19 +81,19 @@ class ReservationController(
override fun createReservationWithPayment( override fun createReservationWithPayment(
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest, @Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest,
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> { ): ResponseEntity<CommonApiResponse<ReservationCreateResponse>> {
val paymentRequest: PaymentApproveRequest = reservationCreateWithPaymentRequest.toPaymentApproveRequest() val paymentRequest: PaymentApproveRequest = reservationCreateWithPaymentRequest.toPaymentApproveRequest()
val paymentResponse: PaymentApproveResponse = paymentClient.confirm(paymentRequest) val paymentResponse: PaymentApproveResponse = paymentClient.confirm(paymentRequest)
try { try {
val reservationRetrieveResponse: ReservationRetrieveResponse = val response: ReservationCreateResponse =
reservationWithPaymentService.createReservationAndPayment( reservationWithPaymentService.createReservationAndPayment(
reservationCreateWithPaymentRequest, reservationCreateWithPaymentRequest,
paymentResponse, paymentResponse,
memberId memberId
) )
return ResponseEntity.created(URI.create("/reservations/${reservationRetrieveResponse.id}")) return ResponseEntity.created(URI.create("/reservations/${response.id}"))
.body(CommonApiResponse(reservationRetrieveResponse)) .body(CommonApiResponse(response))
} catch (e: Exception) { } catch (e: Exception) {
val cancelRequest = PaymentCancelRequest( val cancelRequest = PaymentCancelRequest(
paymentRequest.paymentKey, paymentRequest.paymentKey,
@ -110,10 +112,11 @@ class ReservationController(
@PostMapping("/reservations/admin") @PostMapping("/reservations/admin")
override fun createReservationByAdmin( override fun createReservationByAdmin(
@Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest @Valid @RequestBody adminReservationRequest: AdminReservationCreateRequest,
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> { @MemberId @Parameter(hidden = true) memberId: Long,
val response: ReservationRetrieveResponse = ): ResponseEntity<CommonApiResponse<ReservationCreateResponse>> {
reservationService.createReservationByAdmin(adminReservationRequest) val response: ReservationCreateResponse =
reservationWriteService.createReservationByAdmin(adminReservationRequest, memberId)
return ResponseEntity.created(URI.create("/reservations/${response.id}")) return ResponseEntity.created(URI.create("/reservations/${response.id}"))
.body(CommonApiResponse(response)) .body(CommonApiResponse(response))
@ -121,7 +124,7 @@ class ReservationController(
@GetMapping("/reservations/waiting") @GetMapping("/reservations/waiting")
override fun findAllWaiting(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> { override fun findAllWaiting(): ResponseEntity<CommonApiResponse<ReservationRetrieveListResponse>> {
val response: ReservationRetrieveListResponse = reservationService.findAllWaiting() val response: ReservationRetrieveListResponse = reservationFindService.findAllWaiting()
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@ -130,8 +133,8 @@ class ReservationController(
override fun createWaiting( override fun createWaiting(
@Valid @RequestBody waitingCreateRequest: WaitingCreateRequest, @Valid @RequestBody waitingCreateRequest: WaitingCreateRequest,
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
): ResponseEntity<CommonApiResponse<ReservationRetrieveResponse>> { ): ResponseEntity<CommonApiResponse<ReservationCreateResponse>> {
val response: ReservationRetrieveResponse = reservationService.createWaiting( val response: ReservationCreateResponse = reservationWriteService.createWaiting(
waitingCreateRequest, waitingCreateRequest,
memberId memberId
) )
@ -145,7 +148,7 @@ class ReservationController(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.deleteWaiting(reservationId, memberId) reservationWriteService.deleteWaiting(reservationId, memberId)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@ -155,7 +158,7 @@ class ReservationController(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.confirmWaiting(reservationId, memberId) reservationWriteService.confirmWaiting(reservationId, memberId)
return ResponseEntity.ok().build() return ResponseEntity.ok().build()
} }
@ -165,7 +168,7 @@ class ReservationController(
@MemberId @Parameter(hidden = true) memberId: Long, @MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long @PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<Unit>> {
reservationService.rejectWaiting(reservationId, memberId) reservationWriteService.deleteWaiting(reservationId, memberId)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }

View File

@ -7,12 +7,37 @@ import roomescape.member.web.toRetrieveResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.web.ThemeRetrieveResponse import roomescape.theme.web.ThemeRetrieveResponse
import roomescape.theme.web.toResponse import roomescape.theme.web.toRetrieveResponse
import roomescape.time.web.TimeCreateResponse import roomescape.time.web.TimeCreateResponse
import roomescape.time.web.toCreateResponse import roomescape.time.web.toCreateResponse
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
data class ReservationCreateResponse(
val id: Long,
val date: LocalDate,
@JsonProperty("member")
val member: MemberRetrieveResponse,
@JsonProperty("time")
val time: TimeCreateResponse,
@JsonProperty("theme")
val theme: ThemeRetrieveResponse,
val status: ReservationStatus
)
fun ReservationEntity.toCreateResponse() = ReservationCreateResponse(
id = this.id!!,
date = this.date,
member = this.member.toRetrieveResponse(),
time = this.time.toCreateResponse(),
theme = this.theme.toRetrieveResponse(),
status = this.status
)
data class MyReservationRetrieveResponse( data class MyReservationRetrieveResponse(
val id: Long, val id: Long,
val themeName: String, val themeName: String,
@ -32,17 +57,19 @@ data class MyReservationRetrieveListResponse(
val reservations: List<MyReservationRetrieveResponse> val reservations: List<MyReservationRetrieveResponse>
) )
fun List<MyReservationRetrieveResponse>.toRetrieveListResponse() = MyReservationRetrieveListResponse(this)
data class ReservationRetrieveResponse( data class ReservationRetrieveResponse(
val id: Long, val id: Long,
val date: LocalDate, val date: LocalDate,
@field:JsonProperty("member") @JsonProperty("member")
val member: MemberRetrieveResponse, val member: MemberRetrieveResponse,
@field:JsonProperty("time") @JsonProperty("time")
val time: TimeCreateResponse, val time: TimeCreateResponse,
@field:JsonProperty("theme") @JsonProperty("theme")
val theme: ThemeRetrieveResponse, val theme: ThemeRetrieveResponse,
val status: ReservationStatus val status: ReservationStatus
@ -53,10 +80,14 @@ fun ReservationEntity.toRetrieveResponse(): ReservationRetrieveResponse = Reserv
date = this.date, date = this.date,
member = this.member.toRetrieveResponse(), member = this.member.toRetrieveResponse(),
time = this.time.toCreateResponse(), time = this.time.toCreateResponse(),
theme = this.theme.toResponse(), theme = this.theme.toRetrieveResponse(),
status = this.status status = this.status
) )
data class ReservationRetrieveListResponse( data class ReservationRetrieveListResponse(
val reservations: List<ReservationRetrieveResponse> val reservations: List<ReservationRetrieveResponse>
) )
fun List<ReservationEntity>.toRetrieveListResponse()= ReservationRetrieveListResponse(
this.map { it.toRetrieveResponse() }
)

View File

@ -1,92 +1,67 @@
package roomescape.theme.business package roomescape.theme.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next import roomescape.theme.implement.ThemeFinder
import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.implement.ThemeWriter
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository import roomescape.theme.web.*
import roomescape.theme.web.ThemeCreateRequest
import roomescape.theme.web.ThemeRetrieveListResponse
import roomescape.theme.web.ThemeRetrieveResponse
import roomescape.theme.web.toResponse
import java.time.LocalDate import java.time.LocalDate
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@Service @Service
class ThemeService( class ThemeService(
private val tsidFactory: TsidFactory, private val themeFinder: ThemeFinder,
private val themeRepository: ThemeRepository, private val themeWriter: ThemeWriter,
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findById(id: Long): ThemeEntity { fun findById(id: Long): ThemeEntity {
log.debug { "[ThemeService.findById] 테마 조회 시작: themeId=$id" } log.debug { "[ThemeService.findById] 시작: themeId=$id" }
return themeRepository.findByIdOrNull(id) return themeFinder.findById(id)
?.also { log.info { "[ThemeService.findById] 테마 조회 완료: themeId=$id" } } .also { log.info { "[ThemeService.findById] 완료: themeId=$id, name=${it.name}" } }
?: run {
log.warn { "[ThemeService.findById] 테마 조회 실패: themeId=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findThemes(): ThemeRetrieveListResponse { fun findThemes(): ThemeRetrieveListResponse {
log.debug { "[ThemeService.findThemes] 모든 테마 조회 시작" } log.debug { "[ThemeService.findThemes] 시작" }
return themeRepository.findAll() return themeFinder.findAll()
.also { log.info { "[ThemeService.findThemes] ${it.size}개의 테마 조회 완료" } } .toRetrieveListResponse()
.toResponse() .also { log.info { "[ThemeService.findThemes] 완료. ${it.themes.size}개 반환" } }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findMostReservedThemes(count: Int): ThemeRetrieveListResponse { fun findMostReservedThemes(count: Int): ThemeRetrieveListResponse {
log.debug { "[ThemeService.findMostReservedThemes] 인기 테마 조회 시작: count=$count" } log.debug { "[ThemeService.findMostReservedThemes] 시작: count=$count" }
val today = LocalDate.now() val today = LocalDate.now()
val startDate = today.minusDays(7) val startFrom = today.minusDays(7)
val endDate = today.minusDays(1) val endAt = today.minusDays(1)
return themeRepository.findPopularThemes(startDate, endDate, count) return themeFinder.findMostReservedThemes(count, startFrom, endAt)
.also { log.info { "[ThemeService.findMostReservedThemes] ${it.size} 개의 인기 테마 조회 완료" } } .toRetrieveListResponse()
.toResponse() .also { log.info { "[ThemeService.findMostReservedThemes] ${it.themes.size}개 반환" } }
} }
@Transactional @Transactional
fun createTheme(request: ThemeCreateRequest): ThemeRetrieveResponse { fun createTheme(request: ThemeCreateRequest): ThemeCreateResponse {
log.debug { "[ThemeService.createTheme] 테마 생성 시작: name=${request.name}" } log.debug { "[ThemeService.createTheme] 시작: name=${request.name}" }
if (themeRepository.existsByName(request.name)) { return themeWriter.create(request.name, request.description, request.thumbnail)
log.info { "[ThemeService.createTheme] 테마 생성 실패(이름 중복): name=${request.name}" } .toCreateResponse()
throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) .also { log.info { "[ThemeService.createTheme] 테마 생성 완료: name=${it.name} themeId=${it.id}" } }
}
val theme = ThemeEntity(
_id = tsidFactory.next(),
name = request.name,
description = request.description,
thumbnail = request.thumbnail
)
return themeRepository.save(theme)
.also { log.info { "[ThemeService.createTheme] 테마 생성 완료: themeId=${it.id}" } }
.toResponse()
} }
@Transactional @Transactional
fun deleteTheme(id: Long) { fun deleteTheme(id: Long) {
log.debug { "[ThemeService.deleteTheme] 테마 삭제 시작: themeId=$id" } log.debug { "[ThemeService.deleteTheme] 시작: themeId=$id" }
if (themeRepository.isReservedTheme(id)) { val theme: ThemeEntity = themeFinder.findById(id)
log.info { "[ThemeService.deleteTheme] 테마 삭제 실패(예약이 있는 테마): themeId=$id" }
throw ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED)
}
themeRepository.deleteById(id) themeWriter.delete(theme)
.also { log.info { "[ThemeService.deleteTheme] 테마 삭제 완료: themeId=$id" } } .also { log.info { "[ThemeService.deleteTheme] 완료: themeId=$id, name=${theme.name}" } }
} }
} }

View File

@ -14,6 +14,7 @@ import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.support.LoginRequired
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.theme.web.ThemeCreateRequest import roomescape.theme.web.ThemeCreateRequest
import roomescape.theme.web.ThemeCreateResponse
import roomescape.theme.web.ThemeRetrieveListResponse import roomescape.theme.web.ThemeRetrieveListResponse
import roomescape.theme.web.ThemeRetrieveResponse import roomescape.theme.web.ThemeRetrieveResponse
@ -38,7 +39,7 @@ interface ThemeAPI {
) )
fun createTheme( fun createTheme(
@Valid @RequestBody request: ThemeCreateRequest, @Valid @RequestBody request: ThemeCreateRequest,
): ResponseEntity<CommonApiResponse<ThemeRetrieveResponse>> ): ResponseEntity<CommonApiResponse<ThemeCreateResponse>>
@Admin @Admin
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"])

View File

@ -10,5 +10,6 @@ enum class ThemeErrorCode(
) : ErrorCode { ) : ErrorCode {
THEME_NOT_FOUND(HttpStatus.NOT_FOUND, "TH001", "테마를 찾을 수 없어요."), THEME_NOT_FOUND(HttpStatus.NOT_FOUND, "TH001", "테마를 찾을 수 없어요."),
THEME_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "TH002", "이미 같은 이름의 테마가 있어요."), THEME_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "TH002", "이미 같은 이름의 테마가 있어요."),
THEME_ALREADY_RESERVED(HttpStatus.CONFLICT, "TH003", "예약된 테마라 삭제할 수 없어요.") THEME_ALREADY_RESERVED(HttpStatus.CONFLICT, "TH003", "예약된 테마라 삭제할 수 없어요."),
INVALID_REQUEST_VALUE(HttpStatus.BAD_REQUEST, "TH004", "입력 값이 잘못되었어요."),
} }

View File

@ -0,0 +1,47 @@
package roomescape.theme.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {}
@Component
class ThemeFinder(
private val themeRepository: ThemeRepository
) {
fun findAll(): List<ThemeEntity> {
log.debug { "[ThemeFinder.findAll] 시작" }
return themeRepository.findAll()
.also { log.debug { "[TimeFinder.findAll] ${it.size}개 테마 조회 완료" } }
}
fun findById(id: Long): ThemeEntity {
log.debug { "[ThemeFinder.findById] 조회 시작: memberId=$id" }
return themeRepository.findByIdOrNull(id)
?.also { log.debug { "[ThemeFinder.findById] 조회 완료: id=$id, name=${it.name}" } }
?: run {
log.warn { "[ThemeFinder.findById] 조회 실패: id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
}
fun findMostReservedThemes(
count: Int,
startFrom: LocalDate,
endAt: LocalDate
): List<ThemeEntity> {
log.debug { "[ThemeFinder.findMostReservedThemes] 시작. count=$count, startFrom=$startFrom, endAt=$endAt" }
return themeRepository.findPopularThemes(startFrom, endAt, count)
.also { log.debug { "[ThemeFinder.findMostReservedThemes] ${it.size} / ${count}개 테마 조회 완료" } }
}
}

View File

@ -0,0 +1,43 @@
package roomescape.theme.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
private val log: KLogger = KotlinLogging.logger {}
@Component
class ThemeValidator(
private val themeRepository: ThemeRepository
) {
fun validateNameAlreadyExists(name: String) {
log.debug { "[ThemeValidator.validateNameAlreadyExists] 시작: name=$name" }
if (themeRepository.existsByName(name)) {
log.info { "[ThemeService.createTheme] 이름 중복: name=${name}" }
throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
}
log.debug { "[ThemeValidator.validateNameAlreadyExists] 완료: name=$name" }
}
fun validateIsReserved(theme: ThemeEntity) {
val themeId: Long = theme.id ?: run {
log.warn { "[ThemeValidator.validateIsReserved] ID를 찾을 수 없음: name:${theme.name}" }
throw ThemeException(ThemeErrorCode.INVALID_REQUEST_VALUE)
}
log.debug { "[ThemeValidator.validateIsReserved] 시작: themeId=${themeId}" }
if (themeRepository.isReservedTheme(themeId)) {
log.info { "[ThemeService.deleteTheme] 예약이 있는 테마: themeId=$themeId" }
throw ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED)
}
log.debug { "[ThemeValidator.validateIsReserved] 완료: themeId=$themeId" }
}
}

View File

@ -0,0 +1,41 @@
package roomescape.theme.implement
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
private val log: KLogger = KotlinLogging.logger {}
@Component
class ThemeWriter(
private val themeValidator: ThemeValidator,
private val themeRepository: ThemeRepository,
private val tsidFactory: TsidFactory
) {
fun create(name: String, description: String, thumbnail: String): ThemeEntity {
log.debug { "[ThemeWriter.create] 시작: name=$name" }
themeValidator.validateNameAlreadyExists(name)
val theme = ThemeEntity(
_id = tsidFactory.next(),
name = name,
description = description,
thumbnail = thumbnail
)
return themeRepository.save(theme)
.also { log.debug { "[ThemeWriter.create] 완료: name=$name, id=${it.id}" } }
}
fun delete(theme: ThemeEntity) {
log.debug { "[ThemeWriter.delete] 시작: id=${theme.id}" }
themeValidator.validateIsReserved(theme)
themeRepository.delete(theme)
.also { log.debug { "[ThemeWriter.delete] 완료: id=${theme.id}, name=${theme.name}" } }
}
}

View File

@ -6,27 +6,25 @@ import java.time.LocalDate
interface ThemeRepository : JpaRepository<ThemeEntity, Long> { interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
@Query( @Query(value = """
value = """
SELECT t SELECT t
FROM ThemeEntity t FROM ThemeEntity t
RIGHT JOIN ReservationEntity r ON t.id = r.theme.id RIGHT JOIN ReservationEntity r ON t._id = r.theme._id
WHERE r.date BETWEEN :startDate AND :endDate WHERE r.date BETWEEN :startFrom AND :endAt
GROUP BY r.theme.id GROUP BY r.theme._id
ORDER BY COUNT(r.theme.id) DESC, t.id ASC ORDER BY COUNT(r.theme._id) DESC, t._id ASC
LIMIT :limit LIMIT :count
""" """
) )
fun findPopularThemes(startDate: LocalDate, endDate: LocalDate, limit: Int): List<ThemeEntity> fun findPopularThemes(startFrom: LocalDate, endAt: LocalDate, count: Int): List<ThemeEntity>
fun existsByName(name: String): Boolean fun existsByName(name: String): Boolean
@Query( @Query(value = """
value = """
SELECT EXISTS( SELECT EXISTS(
SELECT 1 SELECT 1
FROM ReservationEntity r FROM ReservationEntity r
WHERE r.theme.id = :id WHERE r.theme._id = :id
) )
""" """
) )

View File

@ -33,8 +33,8 @@ class ThemeController(
@PostMapping("/themes") @PostMapping("/themes")
override fun createTheme( override fun createTheme(
@RequestBody @Valid request: ThemeCreateRequest @RequestBody @Valid request: ThemeCreateRequest
): ResponseEntity<CommonApiResponse<ThemeRetrieveResponse>> { ): ResponseEntity<CommonApiResponse<ThemeCreateResponse>> {
val themeResponse: ThemeRetrieveResponse = themeService.createTheme(request) val themeResponse: ThemeCreateResponse = themeService.createTheme(request)
return ResponseEntity.created(URI.create("/themes/${themeResponse.id}")) return ResponseEntity.created(URI.create("/themes/${themeResponse.id}"))
.body(CommonApiResponse(themeResponse)) .body(CommonApiResponse(themeResponse))

View File

@ -21,6 +21,21 @@ data class ThemeCreateRequest(
val thumbnail: String val thumbnail: String
) )
data class ThemeCreateResponse(
val id: Long,
val name: String,
val description: String,
@Schema(description = "썸네일 이미지 주소(URL).")
val thumbnail: String
)
fun ThemeEntity.toCreateResponse(): ThemeCreateResponse = ThemeCreateResponse(
id = this.id!!,
name = this.name,
description = this.description,
thumbnail = this.thumbnail
)
data class ThemeRetrieveResponse( data class ThemeRetrieveResponse(
val id: Long, val id: Long,
val name: String, val name: String,
@ -29,7 +44,7 @@ data class ThemeRetrieveResponse(
val thumbnail: String val thumbnail: String
) )
fun ThemeEntity.toResponse(): ThemeRetrieveResponse = ThemeRetrieveResponse( fun ThemeEntity.toRetrieveResponse(): ThemeRetrieveResponse = ThemeRetrieveResponse(
id = this.id!!, id = this.id!!,
name = this.name, name = this.name,
description = this.description, description = this.description,
@ -40,6 +55,6 @@ data class ThemeRetrieveListResponse(
val themes: List<ThemeRetrieveResponse> val themes: List<ThemeRetrieveResponse>
) )
fun List<ThemeEntity>.toResponse(): ThemeRetrieveListResponse = ThemeRetrieveListResponse( fun List<ThemeEntity>.toRetrieveListResponse(): ThemeRetrieveListResponse = ThemeRetrieveListResponse(
themes = this.map { it.toResponse() } themes = this.map { it.toRetrieveResponse() }
) )

View File

@ -1,17 +1,11 @@
package roomescape.time.business package roomescape.time.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next import roomescape.time.implement.TimeFinder
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.time.implement.TimeWriter
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeEntity import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.time.web.* import roomescape.time.web.*
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@ -20,87 +14,61 @@ private val log = KotlinLogging.logger {}
@Service @Service
class TimeService( class TimeService(
private val tsidFactory: TsidFactory, private val timeFinder: TimeFinder,
private val timeRepository: TimeRepository, private val timeWriter: TimeWriter,
private val reservationRepository: ReservationRepository,
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findById(id: Long): TimeEntity { fun findById(id: Long): TimeEntity {
log.debug { "[TimeService.findById] 시간 조회 시작: timeId=$id" } log.debug { "[TimeService.findById] 시작: timeId=$id" }
return timeRepository.findByIdOrNull(id)
?.also { log.info { "[TimeService.findById] 시간 조회 완료: timeId=$id" } } return timeFinder.findById(id)
?: run { .also { log.info { "[TimeService.findById] 완료: timeId=$id, startAt=${it.startAt}" } }
log.warn { "[TimeService.findById] 시간 조회 실패: timeId=$id" }
throw TimeException(TimeErrorCode.TIME_NOT_FOUND)
}
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findTimes(): TimeRetrieveListResponse { fun findTimes(): TimeRetrieveListResponse {
log.debug { "[TimeService.findTimes] 모든 시간 조회 시작" } log.debug { "[TimeService.findTimes] 시작" }
return timeRepository.findAll()
.also { log.info { "[TimeService.findTimes] ${it.size}개의 시간 조회 완료" } } return timeFinder.findAll()
.toResponse() .toResponse()
} .also { log.info { "[TimeService.findTimes] 완료. ${it.times.size}개 반환" } }
@Transactional
fun createTime(request: TimeCreateRequest): TimeCreateResponse {
log.debug { "[TimeService.createTime] 시간 생성 시작: startAt=${request.startAt}" }
val startAt: LocalTime = request.startAt
if (timeRepository.existsByStartAt(startAt)) {
log.info { "[TimeService.createTime] 시간 생성 실패(시간 중복): startAt=$startAt" }
throw TimeException(TimeErrorCode.TIME_DUPLICATED)
}
val time = TimeEntity(
_id = tsidFactory.next(),
startAt = request.startAt
)
return timeRepository.save(time)
.also { log.info { "[TimeService.createTime] 시간 생성 완료: timeId=${it.id}" } }
.toCreateResponse()
}
@Transactional
fun deleteTime(id: Long) {
log.debug { "[TimeService.deleteTime] 시간 삭제 시작: timeId=$id" }
val time: TimeEntity = findById(id)
log.debug { "[TimeService.deleteTime] 시간이 ${time.startAt}인 모든 예약 조회 시작" }
val reservations: List<ReservationEntity> = reservationRepository.findAllByTime(time)
log.debug { "[TimeService.deleteTime] 시간이 ${time.startAt}인 모든 ${reservations.size} 개의 예약 조회 완료" }
if (reservations.isNotEmpty()) {
log.info { "[TimeService.deleteTime] 시간 삭제 실패(예약이 있는 시간): timeId=$id" }
throw TimeException(TimeErrorCode.TIME_ALREADY_RESERVED)
}
timeRepository.delete(time)
.also { log.info { "[TimeService.deleteTime] 시간 삭제 완료: timeId=$id" } }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findTimesWithAvailability(date: LocalDate, themeId: Long): TimeWithAvailabilityListResponse { fun findTimesWithAvailability(date: LocalDate, themeId: Long): TimeWithAvailabilityListResponse {
log.debug { "[TimeService.findTimesWithAvailability] 예약 가능 시간 조회 시작: date=$date, themeId=$themeId" } log.debug { "[TimeService.findTimesWithAvailability] 시작: date=$date, themeId=$themeId" }
log.debug { "[TimeService.findTimesWithAvailability] 모든 시간 조회 " } val times: List<TimeWithAvailabilityResponse> =
val allTimes = timeRepository.findAll() timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
log.debug { "[TimeService.findTimesWithAvailability] ${allTimes.size}개의 시간 조회 완료" } .map {
TimeWithAvailabilityResponse(
id = it.timeId,
startAt = it.startAt,
isAvailable = it.isReservable
)
}
log.debug { "[TimeService.findTimesWithAvailability] date=$date, themeId=$themeId 인 모든 예약 조회 시작" } return TimeWithAvailabilityListResponse(times)
val reservations: List<ReservationEntity> = reservationRepository.findByDateAndThemeId(date, themeId) .also { log.info { "[TimeService.findTimesWithAvailability] ${it.times.size}개 반환: date=$date, themeId=$themeId" } }
log.debug { "[TimeService.findTimesWithAvailability] date=$date, themeId=$themeId${reservations.size} 개의 예약 조회 완료" } }
@Transactional
fun createTime(request: TimeCreateRequest): TimeCreateResponse {
val startAt: LocalTime = request.startAt
log.debug { "[TimeService.createTime] 시작: startAt=${startAt}" }
return TimeWithAvailabilityListResponse(allTimes.map { time -> return timeWriter.create(startAt)
val isAvailable: Boolean = reservations.none { reservation -> reservation.time.id == time.id } .toCreateResponse()
TimeWithAvailabilityResponse(time.id!!, time.startAt, isAvailable) .also { log.info { "[TimeService.createTime] 완료: startAt=${startAt}, timeId=${it.id}" } }
}).also { }
log.info {
"[TimeService.findTimesWithAvailability] date=$date, themeId=$themeId 에 대한 예약 가능 여부가 담긴 모든 시간 조회 완료" @Transactional
} fun deleteTime(id: Long) {
} log.debug { "[TimeService.deleteTime] 시작: timeId=$id" }
val time: TimeEntity = timeFinder.findById(id)
timeWriter.delete(time)
.also { log.info { "[TimeService.deleteTime] 완료: timeId=$id, startAt=${time.startAt}" } }
} }
} }

View File

@ -0,0 +1,12 @@
package roomescape.time.business.domain
import java.time.LocalDate
import java.time.LocalTime
class TimeWithAvailability(
val timeId: Long,
val startAt: LocalTime,
val date: LocalDate,
val themeId: Long,
val isReservable: Boolean
)

View File

@ -0,0 +1,60 @@
package roomescape.time.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.time.business.domain.TimeWithAvailability
import roomescape.theme.implement.ThemeFinder
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {}
@Component
class TimeFinder(
private val timeRepository: TimeRepository,
private val reservationFinder: ReservationFinder,
private val themeFinder: ThemeFinder
) {
fun findAll(): List<TimeEntity> {
log.debug { "[TimeFinder.findAll] 시작" }
return timeRepository.findAll()
.also { log.debug { "[TimeFinder.findAll] ${it.size}개 시간 조회 완료" } }
}
fun findById(id: Long): TimeEntity {
log.debug { "[TimeFinder.findById] 조회 시작: timeId=$id" }
return timeRepository.findByIdOrNull(id)
?.also { log.debug { "[TimeFinder.findById] 조회 완료: timeId=$id, startAt=${it.startAt}" } }
?: run {
log.warn { "[TimeFinder.findById] 조회 실패: timeId=$id" }
throw TimeException(TimeErrorCode.TIME_NOT_FOUND)
}
}
fun findAllWithAvailabilityByDateAndThemeId(
date: LocalDate, themeId: Long
): List<TimeWithAvailability> {
log.debug { "[TimeFinder.findAllWithAvailabilityByDateAndThemeId] 조회 시작: date:$date, themeId=$themeId" }
val theme = themeFinder.findById(themeId)
val reservations: List<ReservationEntity> = reservationFinder.findAllByDateAndTheme(date, theme)
val allTimes: List<TimeEntity> = findAll()
return allTimes.map { time ->
val isReservable: Boolean = reservations.any { reservation -> time.id == reservation.id }
TimeWithAvailability(time.id!!, time.startAt, date, themeId, isReservable)
}.also {
log.debug { "[TimeFinder.findAllWithAvailabilityByDateAndThemeId] ${it.size}개 조회 완료: date:$date, themeId=$themeId" }
}
}
}

View File

@ -0,0 +1,41 @@
package roomescape.time.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.reservation.implement.ReservationFinder
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import java.time.LocalTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class TimeValidator(
private val timeRepository: TimeRepository,
private val reservationFinder: ReservationFinder
) {
fun validateIsAlreadyExists(startAt: LocalTime) {
log.debug { "[TimeValidator.validateIsAlreadyExists] 시작: startAt=${startAt}" }
if (timeRepository.existsByStartAt(startAt)) {
log.info { "[TimeValidator.validateIsAlreadyExists] 중복 시간: startAt=$startAt" }
throw TimeException(TimeErrorCode.TIME_DUPLICATED)
}
log.debug { "[TimeValidator.validateIsAlreadyExists] 완료: startAt=${startAt}" }
}
fun validateIsReserved(time: TimeEntity) {
log.debug { "[TimeValidator.validateIsReserved] 시작: id=${time.id}, startAt=${time.startAt}" }
if (reservationFinder.isTimeReserved(time)) {
log.info { "[TimeValidator.validateIsReserved] 예약이 있는 시간: timeId=${time.id}, startAt=${time.startAt}" }
throw TimeException(TimeErrorCode.TIME_ALREADY_RESERVED)
}
log.debug { "[TimeValidator.validateIsReserved] 시작: id=${time.id}, startAt=${time.startAt}" }
}
}

View File

@ -0,0 +1,37 @@
package roomescape.time.implement
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.common.config.next
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import java.time.LocalTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class TimeWriter(
private val timeValidator: TimeValidator,
private val timeRepository: TimeRepository,
private val tsidFactory: TsidFactory
) {
fun create(startAt: LocalTime): TimeEntity {
log.debug { "[TimeWriter.create] 시작: startAt=$startAt" }
timeValidator.validateIsAlreadyExists(startAt)
val time = TimeEntity(_id = tsidFactory.next(), startAt = startAt)
return timeRepository.save(time)
.also { log.debug { "[TimeWriter.create] 완료: startAt=$startAt, id=${it.id}" } }
}
fun delete(time: TimeEntity) {
log.debug { "[TimeWriter.delete] 시작: id=${time.id}" }
timeValidator.validateIsReserved(time)
timeRepository.delete(time)
.also { log.debug { "[TimeWriter.delete] 완료: id=${time.id}, startAt=${time.startAt}" } }
}
}

View File

@ -6,23 +6,21 @@ import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.springframework.data.repository.findByIdOrNull
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.member.business.MemberService import roomescape.member.exception.MemberErrorCode
import roomescape.member.exception.MemberException
import roomescape.member.implement.MemberFinder
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.util.JwtFixture import roomescape.util.JwtFixture
import roomescape.util.MemberFixture import roomescape.util.MemberFixture
import roomescape.util.TsidFactory
class AuthServiceTest : BehaviorSpec({ class AuthServiceTest : BehaviorSpec({
val memberRepository: MemberRepository = mockk() val memberFinder: MemberFinder = mockk()
val memberService = MemberService(TsidFactory, memberRepository)
val jwtHandler: JwtHandler = JwtFixture.create() val jwtHandler: JwtHandler = JwtFixture.create()
val authService = AuthService(memberService, jwtHandler) val authService = AuthService(memberFinder, jwtHandler)
val user: MemberEntity = MemberFixture.user() val user: MemberEntity = MemberFixture.user()
Given("로그인 요청을 받으면") { Given("로그인 요청을 받으면") {
@ -31,7 +29,7 @@ class AuthServiceTest : BehaviorSpec({
Then("회원이 있다면 JWT 토큰을 생성한 뒤 반환한다.") { Then("회원이 있다면 JWT 토큰을 생성한 뒤 반환한다.") {
every { every {
memberRepository.findByEmailAndPassword(request.email, request.password) memberFinder.findByEmailAndPassword(request.email, request.password)
} returns user } returns user
val accessToken: String = authService.login(request).accessToken val accessToken: String = authService.login(request).accessToken
@ -42,8 +40,8 @@ class AuthServiceTest : BehaviorSpec({
Then("회원이 없다면 예외를 던진다.") { Then("회원이 없다면 예외를 던진다.") {
every { every {
memberRepository.findByEmailAndPassword(request.email, request.password) memberFinder.findByEmailAndPassword(request.email, request.password)
} returns null } throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
val exception = shouldThrow<AuthException> { val exception = shouldThrow<AuthException> {
authService.login(request) authService.login(request)
@ -59,7 +57,7 @@ class AuthServiceTest : BehaviorSpec({
val userId: Long = user.id!! val userId: Long = user.id!!
Then("회원이 있다면 회원의 이름을 반환한다.") { Then("회원이 있다면 회원의 이름을 반환한다.") {
every { memberRepository.findByIdOrNull(userId) } returns user every { memberFinder.findById(userId) } returns user
val response = authService.checkLogin(userId) val response = authService.checkLogin(userId)
@ -69,7 +67,9 @@ class AuthServiceTest : BehaviorSpec({
} }
Then("회원이 없다면 예외를 던진다.") { Then("회원이 없다면 예외를 던진다.") {
every { memberRepository.findByIdOrNull(userId) } returns null every {
memberFinder.findById(userId)
} throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
val exception = shouldThrow<AuthException> { val exception = shouldThrow<AuthException> {
authService.checkLogin(userId) authService.checkLogin(userId)

View File

@ -25,14 +25,12 @@ class JwtHandlerTest : FunSpec({
} }
test("만료된 토큰이면 예외를 던진다.") { test("만료된 토큰이면 예외를 던진다.") {
// given
val expirationTime = 0L val expirationTime = 0L
val shortExpirationTimeJwtHandler: JwtHandler = JwtFixture.create(expirationTime = expirationTime) val shortExpirationTimeJwtHandler: JwtHandler = JwtFixture.create(expirationTime = expirationTime)
val token = shortExpirationTimeJwtHandler.createToken(memberId) val token = shortExpirationTimeJwtHandler.createToken(memberId)
Thread.sleep(expirationTime) // 만료 시간 이후로 대기 Thread.sleep(expirationTime) // 만료 시간 이후로 대기
// when & then
shouldThrow<AuthException> { shouldThrow<AuthException> {
shortExpirationTimeJwtHandler.getMemberIdFromToken(token) shortExpirationTimeJwtHandler.getMemberIdFromToken(token)
}.errorCode shouldBe AuthErrorCode.EXPIRED_TOKEN }.errorCode shouldBe AuthErrorCode.EXPIRED_TOKEN

View File

@ -1,24 +1,24 @@
package roomescape.auth.web package roomescape.auth.web
import com.ninjasquad.springmockk.SpykBean import com.ninjasquad.springmockk.MockkBean
import io.mockk.Runs
import io.mockk.every import io.mockk.every
import io.mockk.just
import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.data.repository.findByIdOrNull
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.business.AuthService import roomescape.auth.business.AuthService
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException
import roomescape.common.exception.CommonErrorCode import roomescape.common.exception.CommonErrorCode
import roomescape.common.exception.ErrorCode import roomescape.common.exception.ErrorCode
import roomescape.util.MemberFixture import roomescape.util.MemberFixture
import roomescape.util.RoomescapeApiTest import roomescape.util.RoomescapeApiTest
@WebMvcTest(controllers = [AuthController::class]) @WebMvcTest(controllers = [AuthController::class])
class AuthControllerTest( class AuthControllerTest(val mockMvc: MockMvc) : RoomescapeApiTest() {
val mockMvc: MockMvc
) : RoomescapeApiTest() {
@SpykBean @MockkBean
private lateinit var authService: AuthService private lateinit var authService: AuthService
val userRequest: LoginRequest = MemberFixture.userLoginRequest() val userRequest: LoginRequest = MemberFixture.userLoginRequest()
@ -31,12 +31,8 @@ class AuthControllerTest(
val expectedToken = "expectedToken" val expectedToken = "expectedToken"
every { every {
memberRepository.findByEmailAndPassword(userRequest.email, userRequest.password) authService.login(userRequest)
} returns user } returns LoginResponse(expectedToken)
every {
jwtHandler.createToken(user.id!!)
} returns expectedToken
Then("토큰을 반환한다.") { Then("토큰을 반환한다.") {
runPostTest( runPostTest(
@ -51,12 +47,13 @@ class AuthControllerTest(
} }
When("회원을 찾지 못하면") { When("회원을 찾지 못하면") {
val expectedError = AuthErrorCode.LOGIN_FAILED
every { every {
memberRepository.findByEmailAndPassword(userRequest.email, userRequest.password) authService.login(userRequest)
} returns null } throws AuthException(expectedError)
Then("에러 응답을 받는다.") { Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.LOGIN_FAILED
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
@ -67,6 +64,7 @@ class AuthControllerTest(
} }
} }
} }
When("입력 값이 잘못되면") { When("입력 값이 잘못되면") {
val expectedErrorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE val expectedErrorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
@ -96,6 +94,10 @@ class AuthControllerTest(
loginAsUser() loginAsUser()
Then("회원의 이름과 권한을 응답한다") { Then("회원의 이름과 권한을 응답한다") {
every {
authService.checkLogin(user.id!!)
} returns LoginCheckResponse(user.name, user.role.name)
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
@ -109,12 +111,12 @@ class AuthControllerTest(
When("토큰은 있지만 회원을 찾을 수 없으면") { When("토큰은 있지만 회원을 찾을 수 없으면") {
val invalidMemberId: Long = -1L val invalidMemberId: Long = -1L
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
every { jwtHandler.getMemberIdFromToken(any()) } returns invalidMemberId every { jwtHandler.getMemberIdFromToken(any()) } returns invalidMemberId
every { memberRepository.findByIdOrNull(invalidMemberId) } returns null every { authService.checkLogin(invalidMemberId) } throws AuthException(expectedError)
Then("에러 응답을 받는다.") { Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
@ -129,13 +131,11 @@ class AuthControllerTest(
val endpoint = "/logout" val endpoint = "/logout"
When("토큰으로 memberId 조회가 가능하면") { When("토큰으로 memberId 조회가 가능하면") {
every { loginAsUser()
jwtHandler.getMemberIdFromToken(any())
} returns 1L
every { every {
memberRepository.findByIdOrNull(1L) authService.logout(user.id!!)
} returns MemberFixture.create(id = 1L) } just Runs
Then("정상 응답한다.") { Then("정상 응답한다.") {
runPostTest( runPostTest(

View File

@ -0,0 +1,100 @@
package roomescape.member.business
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import roomescape.member.exception.MemberErrorCode
import roomescape.member.exception.MemberException
import roomescape.member.implement.MemberFinder
import roomescape.member.implement.MemberWriter
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.Role
import roomescape.member.web.SignupRequest
import roomescape.util.MemberFixture
class MemberServiceTest : FunSpec({
val memberWriter: MemberWriter = mockk()
val memberFinder: MemberFinder = mockk()
val memberService = MemberService(memberWriter, memberFinder)
context("findMembers") {
test("정상 응답") {
val members: List<MemberEntity> = listOf(
MemberFixture.create(name = "user1"),
MemberFixture.create(name = "user2"),
)
every { memberFinder.findAll() } returns members
val response = memberService.findMembers()
// then
assertSoftly(response.members) {
it shouldHaveSize 2
it.map { member -> member.name } shouldContainExactly listOf("user1", "user2")
}
}
}
context("findById") {
val id = 1L
test("정상 응답") {
every {
memberFinder.findById(id)
} returns MemberFixture.create(id = id)
memberService.findById(id).id shouldBe id
}
test("회원을 찾을 수 없으면 예외 응답") {
every {
memberFinder.findById(id)
} throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
shouldThrow<MemberException> {
memberService.findById(id)
}.also {
it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND
}
}
}
context("createMember") {
val request = SignupRequest(name = "new-user", email = "new@test.com", password = "password")
test("정상 저장") {
val member = MemberFixture.create(
name = request.name,
account = request.email,
password = request.password
)
every {
memberWriter.create(request.name, request.email, request.password, Role.MEMBER)
} returns member
val response = memberService.createMember(request)
response.id shouldBe member.id
}
test("중복된 이메일이 있으면 예외 응답") {
every {
memberWriter.create(request.name, request.email, request.password, Role.MEMBER)
} throws MemberException(MemberErrorCode.DUPLICATE_EMAIL)
shouldThrow<MemberException> {
memberService.createMember(request)
}.also {
it.errorCode shouldBe MemberErrorCode.DUPLICATE_EMAIL
}
}
}
})

View File

@ -1,57 +1,53 @@
package roomescape.member.controller package roomescape.member.controller
import com.ninjasquad.springmockk.MockkBean
import io.kotest.assertions.assertSoftly import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.collections.shouldContainAll
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.mockk
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.member.business.MemberService
import roomescape.member.exception.MemberErrorCode import roomescape.member.exception.MemberErrorCode
import roomescape.member.exception.MemberException
import roomescape.member.infrastructure.persistence.Role import roomescape.member.infrastructure.persistence.Role
import roomescape.member.web.MemberController import roomescape.member.web.*
import roomescape.member.web.MemberRetrieveListResponse
import roomescape.member.web.SignupRequest
import roomescape.util.MemberFixture import roomescape.util.MemberFixture
import roomescape.util.RoomescapeApiTest import roomescape.util.RoomescapeApiTest
import kotlin.random.Random import kotlin.random.Random
@WebMvcTest(controllers = [MemberController::class]) @WebMvcTest(controllers = [MemberController::class])
class MemberControllerTest( class MemberControllerTest(val mockMvc: MockMvc) : RoomescapeApiTest() {
@Autowired private val mockMvc: MockMvc @MockkBean
) : RoomescapeApiTest() { private lateinit var memberService: MemberService
init { init {
given("GET /members 요청을") { given("GET /members 요청을") {
val endpoint = "/members" val endpoint = "/members"
val response = listOf(
every { memberRepository.findAll() } returns listOf(
MemberFixture.create(id = Random.nextLong(), name = "name1"), MemberFixture.create(id = Random.nextLong(), name = "name1"),
MemberFixture.create(id = Random.nextLong(), name = "name2"), MemberFixture.create(id = Random.nextLong(), name = "name2"),
MemberFixture.create(id = Random.nextLong(), name = "name3"), MemberFixture.create(id = Random.nextLong(), name = "name3"),
) ).toRetrieveListResponse()
every { memberService.findMembers() } returns response
`when`("관리자가 보내면") { `when`("관리자가 보내면") {
loginAsAdmin() loginAsAdmin()
then("성공한다.") { then("성공한다.") {
val result: String = runGetTest( val result: MemberRetrieveListResponse = runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { isOk() } status { isOk() }
}.andReturn().response.contentAsString }.andReturn().readValue(MemberRetrieveListResponse::class.java)
val response: MemberRetrieveListResponse = readValue(
responseJson = result,
valueType = MemberRetrieveListResponse::class.java
)
assertSoftly(response.members) { assertSoftly(result.members) {
it.size shouldBe 3 it.size shouldBe response.members.size
it.map { m -> m.name } shouldContainAll listOf("name1", "name2", "name3") it.map { m -> m.name } shouldContainAll response.members.map { m -> m.name }
} }
} }
} }
@ -96,18 +92,14 @@ class MemberControllerTest(
) )
`when`("같은 이메일이 없으면") { `when`("같은 이메일이 없으면") {
every { every {
memberRepository.findByEmail(request.email) memberService.createMember(request)
} returns null
every {
memberRepository.save(any())
} returns MemberFixture.create( } returns MemberFixture.create(
id = 1, id = 1,
name = request.name, name = request.name,
account = request.email, account = request.email,
password = request.password, password = request.password,
role = Role.MEMBER role = Role.MEMBER
) ).toSignupResponse()
then("id과 이름을 담아 성공 응답") { then("id과 이름을 담아 성공 응답") {
runPostTest( runPostTest(
@ -123,13 +115,12 @@ class MemberControllerTest(
} }
`when`("같은 이메일이 있으면") { `when`("같은 이메일이 있으면") {
val expectedError = MemberErrorCode.DUPLICATE_EMAIL
every { every {
memberRepository.findByEmail(request.email) memberService.createMember(request)
} returns mockk() } throws MemberException(expectedError)
then("에러 응답") { then("에러 응답") {
val expectedError = MemberErrorCode.DUPLICATE_EMAIL
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
@ -138,7 +129,6 @@ class MemberControllerTest(
status { isEqualTo(expectedError.httpStatus.value()) } status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.code") { value(expectedError.errorCode) } jsonPath("$.code") { value(expectedError.errorCode) }
} }
} }
} }
} }

View File

@ -0,0 +1,85 @@
package roomescape.member.implement
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.springframework.data.repository.findByIdOrNull
import roomescape.member.exception.MemberErrorCode
import roomescape.member.exception.MemberException
import roomescape.member.infrastructure.persistence.MemberRepository
class MemberFinderTest : FunSpec({
val memberRepository: MemberRepository = mockk()
val memberFinder = MemberFinder(memberRepository)
context("findAll") {
test("모든 회원을 조회한다.") {
every {
memberRepository.findAll()
} returns listOf(mockk(), mockk(), mockk())
memberFinder.findAll() shouldHaveSize 3
}
}
context("findById") {
val memberId = 1L
test("동일한 ID인 회원을 찾아 응답한다.") {
every {
memberRepository.findByIdOrNull(memberId)
} returns mockk()
memberFinder.findById(memberId)
verify(exactly = 1) {
memberRepository.findByIdOrNull(memberId)
}
}
test("동일한 ID인 회원이 없으면 실패한다.") {
every {
memberRepository.findByIdOrNull(memberId)
} returns null
shouldThrow<MemberException> {
memberFinder.findById(memberId)
}.also {
it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND
}
}
}
context("findByEmailAndPassword") {
val email = "email"
val password = "password"
test("동일한 이메일과 비밀번호를 가진 회원을 찾아 응답한다.") {
every {
memberRepository.findByEmailAndPassword(email, password)
} returns mockk()
memberFinder.findByEmailAndPassword(email, password)
verify(exactly = 1) {
memberRepository.findByEmailAndPassword(email, password)
}
}
test("동일한 이메일과 비밀번호를 가진 회원이 없으면 실패한다.") {
every {
memberRepository.findByEmailAndPassword(email, password)
} returns null
shouldThrow<MemberException> {
memberFinder.findByEmailAndPassword(email, password)
}.also {
it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND
}
}
}
})

View File

@ -0,0 +1,44 @@
package roomescape.member.implement
import io.kotest.assertions.throwables.shouldNotThrow
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import roomescape.member.exception.MemberErrorCode
import roomescape.member.exception.MemberException
import roomescape.member.infrastructure.persistence.MemberRepository
class MemberValidatorTest : FunSpec({
val memberRepository: MemberRepository = mockk()
val memberValidator = MemberValidator(memberRepository)
context("validateCanSignup") {
val email = "email@email.com"
test("같은 이메일을 가진 회원이 있으면 예외를 던진다.") {
every {
memberRepository.existsByEmail(email)
} returns true
shouldThrow<MemberException> {
memberValidator.validateCanSignup(email)
}.also {
it.errorCode shouldBe MemberErrorCode.DUPLICATE_EMAIL
}
}
test("같은 이메일을 가진 회원이 없으면 종료한다.") {
every {
memberRepository.existsByEmail(email)
} returns false
shouldNotThrow<MemberException> {
memberValidator.validateCanSignup(email)
}
}
}
})

View File

@ -0,0 +1,65 @@
package roomescape.member.implement
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.spyk
import io.mockk.verify
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.infrastructure.persistence.Role
import roomescape.util.MemberFixture
import roomescape.util.TsidFactory
class MemberWriterTest : FunSpec({
val memberRepository: MemberRepository = mockk()
val memberValidator = MemberValidator(memberRepository)
val memberWriter = MemberWriter(TsidFactory, memberValidator, memberRepository)
context("create") {
val name = "name"
val email = "email"
val password = "password"
val role = Role.MEMBER
test("중복된 이메일이 있으면 실패한다.") {
every {
memberRepository.existsByEmail(any())
} returns true
shouldThrow<MemberException> {
memberWriter.create(name, email, password, role)
}.also {
it.errorCode shouldBe MemberErrorCode.DUPLICATE_EMAIL
}
}
test("중복된 이메일이 없으면 저장한다.") {
every {
memberRepository.existsByEmail(any())
} returns false
every {
memberRepository.save(any())
} returns MemberFixture.create(
name = name,
account = email,
password = password,
role = role
)
memberWriter.create(name, email, password, role)
verify(exactly = 1) {
memberRepository.save(any())
}
}
}
})

View File

@ -0,0 +1,63 @@
package roomescape.member.infrastructure.persistence
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import jakarta.persistence.EntityManager
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.util.MemberFixture
@DataJpaTest(showSql = true)
class MemberRepositoryTest(
val entityManager: EntityManager,
val memberRepository: MemberRepository
) : FunSpec({
context("existsByEmail") {
val account = "email"
val email = "$account@email.com"
beforeTest {
entityManager.persist(MemberFixture.create(account = account))
entityManager.flush()
entityManager.clear()
}
test("동일한 이메일이 있으면 true 반환") {
val result = memberRepository.existsByEmail(email)
result shouldBe true
}
test("동일한 이메일이 없으면 false 반환") {
memberRepository.existsByEmail(email.substring(email.length - 1)) shouldBe false
}
}
context("findByEmailAndPassword") {
val account = "email"
val email = "$account@email.com"
val password = "password123"
beforeTest {
entityManager.persist(MemberFixture.create(account = account, password = password))
entityManager.flush()
entityManager.clear()
}
test("둘다 일치하면 정상 반환") {
memberRepository.findByEmailAndPassword(email, password) shouldNotBeNull {
this.email shouldBe email
this.password shouldBe password
}
}
test("이메일이 틀리면 null 반환") {
val invalidMail = email.substring(email.length - 1)
memberRepository.findByEmailAndPassword(invalidMail, password) shouldBe null
}
test("비밀번호가 틀리면 null 반환") {
val invalidPassword = password.substring(password.length - 1)
memberRepository.findByEmailAndPassword(email, invalidPassword) shouldBe null
}
}
})

View File

@ -4,114 +4,162 @@ import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs import io.mockk.slot
import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository import roomescape.payment.implement.PaymentFinder
import roomescape.payment.infrastructure.persistence.PaymentRepository import roomescape.payment.implement.PaymentWriter
import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse
import roomescape.util.PaymentFixture import roomescape.util.PaymentFixture
import roomescape.util.TsidFactory import roomescape.util.ReservationFixture
import java.time.LocalDate
import java.time.LocalTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset
class PaymentServiceTest : FunSpec({ class PaymentServiceTest : FunSpec({
val paymentRepository: PaymentRepository = mockk() val paymentFinder: PaymentFinder = mockk()
val canceledPaymentRepository: CanceledPaymentRepository = mockk() val paymentWriter: PaymentWriter = mockk()
val paymentService = PaymentService(TsidFactory, paymentRepository, canceledPaymentRepository) val paymentService = PaymentService(paymentFinder, paymentWriter)
context("createCanceledPaymentByReservationId") { context("createPayment") {
val reservationId = 1L val approvedPaymentInfo = PaymentApproveResponse(
test("reservationId로 paymentKey를 찾을 수 없으면 예외를 던진다.") { paymentKey = "paymentKey",
every { paymentRepository.findPaymentKeyByReservationId(reservationId) } returns null orderId = "orderId",
totalAmount = 1000L,
approvedAt = OffsetDateTime.now(),
)
val reservation = ReservationFixture.create(id = 1L)
val exception = shouldThrow<PaymentException> { test("정상 응답") {
paymentService.createCanceledPaymentByReservationId(reservationId) every {
paymentWriter.create(
paymentKey = approvedPaymentInfo.paymentKey,
orderId = approvedPaymentInfo.orderId,
totalAmount = approvedPaymentInfo.totalAmount,
approvedAt = approvedPaymentInfo.approvedAt,
reservation = reservation
)
} returns PaymentFixture.create(
id = 1L,
orderId = approvedPaymentInfo.orderId,
paymentKey = approvedPaymentInfo.paymentKey,
totalAmount = approvedPaymentInfo.totalAmount,
approvedAt = approvedPaymentInfo.approvedAt,
reservation = reservation
)
val response = paymentService.createPayment(approvedPaymentInfo, reservation)
assertSoftly(response) {
it.id shouldBe 1L
it.paymentKey shouldBe approvedPaymentInfo.paymentKey
it.reservationId shouldBe reservation.id
} }
exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
} }
}
context("reservationId로 paymentKey를 찾고난 후") { context("createCanceledPayment(canceledPaymentInfo)") {
val paymentKey = "test-payment-key" val canceledPaymentInfo = PaymentCancelResponse(
cancelStatus = "normal",
cancelReason = "고객 요청",
cancelAmount = 1000L,
canceledAt = OffsetDateTime.now(),
)
val approvedAt = OffsetDateTime.now()
val paymentKey = "paymentKey"
test("CanceledPaymentEntity를 응답") {
every {
paymentWriter.createCanceled(
cancelReason = canceledPaymentInfo.cancelReason,
cancelAmount = canceledPaymentInfo.cancelAmount,
canceledAt = canceledPaymentInfo.canceledAt,
approvedAt = approvedAt,
paymentKey = paymentKey
)
} returns PaymentFixture.createCanceled(
id = 1L,
paymentKey = paymentKey,
cancelAmount = canceledPaymentInfo.cancelAmount
)
val response = paymentService.createCanceledPayment(canceledPaymentInfo, approvedAt, paymentKey)
response.shouldBeInstanceOf<CanceledPaymentEntity>()
response.paymentKey shouldBe paymentKey
}
}
context("createCanceledPayment(reservationId)") {
val reservationId = 1L
test("취소 사유를 '예약 취소'로 하여 PaymentCancelRequest를 응답") {
val payment = PaymentFixture.create(id = 1L, paymentKey = "paymentKey", totalAmount = 1000L)
every {
paymentFinder.findByReservationId(reservationId)
} returns payment
val cancelReasonSlot = slot<String>()
every { every {
paymentRepository.findPaymentKeyByReservationId(reservationId) paymentWriter.createCanceled(payment, capture(cancelReasonSlot), any())
} returns paymentKey } returns PaymentFixture.createCanceled(
id = 1L,
paymentKey = payment.paymentKey,
cancelAmount = payment.totalAmount
)
test("해당 paymentKey로 paymentEntity를 찾을 수 없으면 예외를 던진다.") { val response = paymentService.createCanceledPayment(reservationId)
every {
paymentRepository.findByPaymentKey(paymentKey)
} returns null
val exception = shouldThrow<PaymentException> { response.shouldBeInstanceOf<PaymentCancelRequest>()
paymentService.createCanceledPaymentByReservationId(reservationId) cancelReasonSlot.captured shouldBe "예약 취소"
} }
exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
}
test("해당 paymentKey로 paymentEntity를 찾고, cancelPaymentEntity를 저장한다.") { test("결제 정보가 없으면 예외 응답") {
val paymentEntity = PaymentFixture.create(paymentKey = paymentKey) every {
paymentFinder.findByReservationId(reservationId)
} throws PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
every { shouldThrow<PaymentException> {
paymentRepository.findByPaymentKey(paymentKey) paymentService.createCanceledPayment(reservationId)
} returns paymentEntity.also { }.also {
every { it.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
paymentRepository.delete(it)
} just runs
}
every {
canceledPaymentRepository.save(any())
} returns PaymentFixture.createCanceled(
id = 1L,
paymentKey = paymentKey,
cancelReason = "Test",
cancelAmount = paymentEntity.totalAmount,
)
val result: PaymentCancelRequest = paymentService.createCanceledPaymentByReservationId(reservationId)
assertSoftly(result) {
this.paymentKey shouldBe paymentKey
this.amount shouldBe paymentEntity.totalAmount
this.cancelReason shouldBe "Test"
}
} }
} }
} }
context("updateCanceledTime") { context("updateCanceledTime") {
val paymentKey = "test-payment-key" val paymentKey = "paymentKey"
val canceledAt = OffsetDateTime.now() val canceledAt = OffsetDateTime.of(LocalDate.of(2025, 8, 5), LocalTime.of(10, 0), ZoneOffset.UTC)
test("paymentKey로 canceledPaymentEntity를 찾을 수 없으면 예외를 던진다.") { test("정상 응답") {
val canceled = PaymentFixture.createCanceled(id = 1L)
every { every {
canceledPaymentRepository.findByPaymentKey(paymentKey) paymentFinder.findCanceledByKey(paymentKey)
} returns null } returns canceled
val exception = shouldThrow<PaymentException> {
paymentService.updateCanceledTime(paymentKey, canceledAt)
}
exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
}
test("paymentKey로 canceledPaymentEntity를 찾고, canceledAt을 업데이트한다.") {
val canceledPaymentEntity = PaymentFixture.createCanceled(
paymentKey = paymentKey,
canceledAt = canceledAt.minusMinutes(1)
)
every {
canceledPaymentRepository.findByPaymentKey(paymentKey)
} returns canceledPaymentEntity
paymentService.updateCanceledTime(paymentKey, canceledAt) paymentService.updateCanceledTime(paymentKey, canceledAt)
assertSoftly(canceledPaymentEntity) { canceled.canceledAt shouldBe canceledAt
this.canceledAt shouldBe canceledAt }
test("결제 취소 정보가 없으면 예외 응답") {
every {
paymentFinder.findCanceledByKey(paymentKey)
} throws PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
shouldThrow<PaymentException> {
paymentService.updateCanceledTime(paymentKey, canceledAt)
}.also {
it.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
} }
} }
} }

View File

@ -0,0 +1,93 @@
package roomescape.payment.implement
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 io.mockk.verify
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentRepository
class PaymentFinderTest : FunSpec({
val paymentRepository: PaymentRepository = mockk()
val canceledPaymentRepository: CanceledPaymentRepository = mockk()
val paymentFinder = PaymentFinder(paymentRepository, canceledPaymentRepository)
context("existsPaymentByReservationId") {
val reservationId = 1L
test("결제 정보가 있으면 true를 반환한다.") {
every {
paymentRepository.existsByReservationId(reservationId)
} returns true
paymentFinder.existsPaymentByReservationId(reservationId) shouldBe true
}
test("결제 정보가 없으면 false를 반환한다.") {
every {
paymentRepository.existsByReservationId(reservationId)
} returns false
paymentFinder.existsPaymentByReservationId(reservationId) shouldBe false
}
}
context("findByReservationId") {
val reservationId = 1L
test("결제 정보를 조회한다.") {
every {
paymentRepository.findByReservationId(reservationId)
} returns mockk()
paymentFinder.findByReservationId(reservationId)
verify(exactly = 1) {
paymentRepository.findByReservationId(reservationId)
}
}
test("결제 정보가 없으면 실패한다.") {
every {
paymentRepository.findByReservationId(reservationId)
} returns null
shouldThrow<PaymentException> {
paymentFinder.findByReservationId(reservationId)
}.also {
it.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
}
}
}
context("findCanceledByKey") {
val paymentKey = "paymentKey"
test("결제 취소 정보를 조회한다.") {
every {
canceledPaymentRepository.findByPaymentKey(paymentKey)
} returns mockk()
paymentFinder.findCanceledByKey(paymentKey)
verify(exactly = 1) {
canceledPaymentRepository.findByPaymentKey(paymentKey)
}
}
test("결제 취소 정보가 없으면 실패한다.") {
every {
canceledPaymentRepository.findByPaymentKey(paymentKey)
} returns null
shouldThrow<PaymentException> {
paymentFinder.findCanceledByKey(paymentKey)
}.also {
it.errorCode shouldBe PaymentErrorCode.CANCELED_PAYMENT_NOT_FOUND
}
}
}
})

View File

@ -0,0 +1,121 @@
package roomescape.payment.implement
import com.ninjasquad.springmockk.MockkClear
import com.ninjasquad.springmockk.clear
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.date.after
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.infrastructure.persistence.PaymentRepository
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.util.PaymentFixture
import roomescape.util.ReservationFixture
import roomescape.util.TsidFactory
import java.time.OffsetDateTime
class PaymentWriterTest : FunSpec({
val paymentRepository: PaymentRepository = mockk()
val canceledPaymentRepository: CanceledPaymentRepository = mockk()
val paymentWriter = PaymentWriter(paymentRepository, canceledPaymentRepository, TsidFactory)
val paymentKey = "paymentKey"
val orderId = "orderId"
val totalAmount = 1000L
val approvedAt = OffsetDateTime.now()
context("create") {
val reservation: ReservationEntity = ReservationFixture.create(id = 1L)
test("결제 정보를 저장한다.") {
val slot = slot<PaymentEntity>()
every {
paymentRepository.save(capture(slot))
} returns mockk()
paymentWriter.create(paymentKey, orderId, totalAmount, approvedAt, reservation)
verify(exactly = 1) {
paymentRepository.save(any())
}
slot.captured.also {
it.paymentKey shouldBe paymentKey
it.orderId shouldBe orderId
it.totalAmount shouldBe totalAmount
it.approvedAt shouldBe approvedAt
}
paymentRepository.clear(MockkClear.AFTER)
}
}
context("createCanceled") {
val cancelReason = "고객 요청"
val canceledAt = OffsetDateTime.now()
afterTest {
clearMocks(paymentRepository)
clearMocks(canceledPaymentRepository)
}
test("PaymentEntity를 받아 저장한다.") {
val payment: PaymentEntity = PaymentFixture.create(id = 1L)
val slot = slot<CanceledPaymentEntity>()
every {
canceledPaymentRepository.save(capture(slot))
} returns mockk()
every {
paymentRepository.deleteByPaymentKey(paymentKey)
} returns Unit
paymentWriter.createCanceled(payment, cancelReason, canceledAt)
verify(exactly = 1) { canceledPaymentRepository.save(any()) }
verify(exactly = 1) { paymentRepository.deleteByPaymentKey(any()) }
slot.captured.also {
it.paymentKey shouldBe payment.paymentKey
it.cancelAmount shouldBe payment.totalAmount
it.approvedAt shouldBe payment.approvedAt
}
}
test("취소 정보를 받아 저장한다.") {
val slot = slot<CanceledPaymentEntity>()
every {
canceledPaymentRepository.save(capture(slot))
} returns mockk()
every {
paymentRepository.deleteByPaymentKey(paymentKey)
} returns Unit
paymentWriter.createCanceled(
cancelReason = cancelReason,
cancelAmount = totalAmount,
canceledAt = canceledAt,
approvedAt = approvedAt,
paymentKey = paymentKey
)
verify(exactly = 1) { canceledPaymentRepository.save(any()) }
verify(exactly = 1) { paymentRepository.deleteByPaymentKey(any()) }
slot.captured.also {
it.paymentKey shouldBe paymentKey
it.cancelAmount shouldBe totalAmount
it.approvedAt shouldBe approvedAt
}
}
}
})

View File

@ -48,7 +48,6 @@ class TossPaymentClientTest(
.createResponse(it) .createResponse(it)
} }
// when
val paymentRequest = SampleTossPaymentConst.paymentRequest val paymentRequest = SampleTossPaymentConst.paymentRequest
val paymentResponse: PaymentApproveResponse = client.confirm(paymentRequest) val paymentResponse: PaymentApproveResponse = client.confirm(paymentRequest)
@ -68,7 +67,6 @@ class TossPaymentClientTest(
.createResponse(it) .createResponse(it)
} }
// when
val paymentRequest = SampleTossPaymentConst.paymentRequest val paymentRequest = SampleTossPaymentConst.paymentRequest
// then // then
@ -107,7 +105,7 @@ class TossPaymentClientTest(
.createResponse(it) .createResponse(it)
} }
// when
val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest
val cancelResponse: PaymentCancelResponse = client.cancel(cancelRequest) val cancelResponse: PaymentCancelResponse = client.cancel(cancelRequest)

View File

@ -38,58 +38,6 @@ class PaymentRepositoryTest(
.also { it shouldBe false } .also { it shouldBe false }
} }
} }
context("findPaymentKeyByReservationId") {
lateinit var paymentKey: String
beforeTest {
reservation = setupReservation()
paymentKey = PaymentFixture.create(reservation = reservation)
.also { paymentRepository.save(it) }
.paymentKey
}
test("정상 반환") {
paymentRepository.findPaymentKeyByReservationId(reservation.id!!)
?.let { it shouldBe paymentKey }
?: throw AssertionError("Unexpected null value")
}
test("null 반환") {
paymentRepository.findPaymentKeyByReservationId(reservation.id!! + 1)
.also { it shouldBe null }
}
}
context("findByPaymentKey") {
lateinit var payment: PaymentEntity
beforeTest {
reservation = setupReservation()
payment = PaymentFixture.create(reservation = reservation)
.also { paymentRepository.save(it) }
}
test("정상 반환") {
paymentRepository.findByPaymentKey(payment.paymentKey)
?.also {
assertSoftly(it) {
this.id shouldBe payment.id
this.orderId shouldBe payment.orderId
this.paymentKey shouldBe payment.paymentKey
this.totalAmount shouldBe payment.totalAmount
this.reservation.id shouldBe payment.reservation.id
this.approvedAt shouldBe payment.approvedAt
}
}
?: throw AssertionError("Unexpected null value")
}
test("null 반환") {
paymentRepository.findByPaymentKey("non-existent-key")
.also { it shouldBe null }
}
}
} }
private fun setupReservation(): ReservationEntity { private fun setupReservation(): ReservationEntity {

View File

@ -0,0 +1,284 @@
package roomescape.reservation.business
import io.kotest.assertions.assertSoftly
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.*
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.implement.ReservationWriter
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture
import roomescape.util.ThemeFixture
import roomescape.util.TimeFixture
class ReservationCommandServiceTest : FunSpec({
val reservationFinder: ReservationFinder = mockk()
val reservationWriter: ReservationWriter = mockk()
val reservationWriteService = ReservationWriteService(reservationFinder, reservationWriter)
context("createReservationWithPayment") {
val request = ReservationFixture.createRequest()
val memberId = 1L
test("정상 응답") {
val createdReservation = ReservationFixture.create(
date = request.date,
time = TimeFixture.create(id = request.timeId),
theme = ThemeFixture.create(id = request.themeId),
member = MemberFixture.create(id = memberId),
status = ReservationStatus.CONFIRMED
)
every {
reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.CONFIRMED,
memberId = memberId,
requesterId = memberId
)
} returns createdReservation
val result = reservationWriteService.createReservationWithPayment(request, memberId)
assertSoftly(result) {
this.date shouldBe request.date
this.time.id shouldBe request.timeId
this.theme.id shouldBe request.themeId
this.member.id shouldBe memberId
this.status shouldBe ReservationStatus.CONFIRMED
}
}
test("예약 생성에 실패하면 예외 응답") {
every {
reservationWriter.create(any(), any(), any(), any(), any(), any())
} throws ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED)
shouldThrow<ReservationException> {
reservationWriteService.createReservationWithPayment(request, memberId)
}.also {
it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED
}
}
}
context("createReservationByAdmin") {
val request = ReservationFixture.createAdminRequest()
val adminId = request.memberId + 1
test("정상 응답") {
val createdReservation = ReservationFixture.create(
date = request.date,
time = TimeFixture.create(id = request.timeId),
theme = ThemeFixture.create(id = request.themeId),
member = MemberFixture.create(id = request.memberId),
status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
)
every {
reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED,
memberId = request.memberId,
requesterId = adminId
)
} returns createdReservation
val response = reservationWriteService.createReservationByAdmin(request, adminId)
assertSoftly(response) {
this.date shouldBe request.date
this.time.id shouldBe request.timeId
this.theme.id shouldBe request.themeId
this.member.id shouldBe request.memberId
this.status shouldBe ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
}
}
}
context("createWaiting") {
val request = ReservationFixture.createWaitingRequest()
val memberId = 1L
test("정상 응답") {
val createdWaiting = ReservationFixture.create(
date = request.date,
time = TimeFixture.create(id = request.timeId),
theme = ThemeFixture.create(id = request.themeId),
member = MemberFixture.create(id = memberId),
status = ReservationStatus.WAITING
)
every {
reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.WAITING,
memberId = memberId,
requesterId = memberId
)
} returns createdWaiting
val response = reservationWriteService.createWaiting(request, memberId)
assertSoftly(response) {
this.date shouldBe request.date
this.time.id shouldBe request.timeId
this.theme.id shouldBe request.themeId
this.member.id shouldBe memberId
this.status shouldBe ReservationStatus.WAITING
}
}
test("이미 예약한 내역이 있으면 예외 응답") {
every {
reservationWriter.create(any(), any(), any(), any(), any(), any())
} throws ReservationException(ReservationErrorCode.ALREADY_RESERVE)
shouldThrow<ReservationException> {
reservationWriteService.createWaiting(request, memberId)
}.also {
it.errorCode shouldBe ReservationErrorCode.ALREADY_RESERVE
}
}
}
context("deleteReservation") {
val reservationId = 1L
val memberId = 1L
val reservation = ReservationFixture.create(id = reservationId, member = MemberFixture.create(id = memberId))
test("정상 응답") {
every { reservationFinder.findById(reservationId) } returns reservation
every { reservationWriter.deleteConfirmed(reservation, memberId) } just Runs
shouldNotThrow<Exception> {
reservationWriteService.deleteReservation(reservationId, memberId)
}
verify(exactly = 1) { reservationWriter.deleteConfirmed(reservation, memberId) }
}
test("예약을 찾을 수 없으면 예외 응답") {
every {
reservationFinder.findById(reservationId)
} throws ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
shouldThrow<ReservationException> {
reservationWriteService.deleteReservation(reservationId, memberId)
}.also {
it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND
}
}
test("삭제하려는 회원이 관리자가 아니고, 대기한 회원과 다르면 예외 응답") {
every { reservationFinder.findById(reservationId) } returns reservation
every {
reservationWriter.deleteConfirmed(reservation, memberId)
} throws ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
shouldThrow<ReservationException> {
reservationWriteService.deleteReservation(reservationId, memberId)
}.also {
it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER
}
}
}
context("confirmWaiting") {
val reservationId = 1L
val memberId = 99L // Admin
test("정상 응답") {
every { reservationWriter.confirm(reservationId) } just Runs
shouldNotThrow<Exception> {
reservationWriteService.confirmWaiting(reservationId, memberId)
}
verify(exactly = 1) { reservationWriter.confirm(reservationId) }
}
test("이미 확정된 예약이 있으면 예외 응답") {
every {
reservationWriter.confirm(reservationId)
} throws ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS)
shouldThrow<ReservationException> {
reservationWriteService.confirmWaiting(reservationId, memberId)
}.also {
it.errorCode shouldBe ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS
}
}
}
context("deleteWaiting") {
val reservationId = 1L
val memberId = 1L
val waitingReservation = ReservationFixture.create(
id = reservationId,
member = MemberFixture.create(id = memberId),
status = ReservationStatus.WAITING
)
test("정상 응답") {
every { reservationFinder.findById(reservationId) } returns waitingReservation
every { reservationWriter.deleteWaiting(waitingReservation, memberId) } just Runs
shouldNotThrow<Exception> {
reservationWriteService.deleteWaiting(reservationId, memberId)
}
verify(exactly = 1) { reservationWriter.deleteWaiting(waitingReservation, memberId) }
}
test("예약을 찾을 수 없으면 예외 응답") {
every {
reservationFinder.findById(reservationId)
} throws ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
shouldThrow< ReservationException> {
reservationWriteService.deleteWaiting(reservationId, memberId)
}.also {
it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND
}
}
test("대기 상태가 아니면 예외 응답") {
every { reservationFinder.findById(reservationId) } returns waitingReservation
every {
reservationWriter.deleteWaiting(waitingReservation, memberId)
} throws ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
shouldThrow<ReservationException> {
reservationWriteService.deleteWaiting(reservationId, memberId)
}.also {
it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED
}
}
test("삭제하려는 회원이 관리자가 아니고, 대기한 회원과 다르면 예외 응답") {
every { reservationFinder.findById(reservationId) } returns waitingReservation
every {
reservationWriter.deleteWaiting(waitingReservation, memberId)
} throws ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
shouldThrow<ReservationException> {
reservationWriteService.deleteWaiting(reservationId, memberId)
}.also {
it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER
}
}
}
})

View File

@ -0,0 +1,118 @@
package roomescape.reservation.business
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.MyReservationRetrieveResponse
import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture
import roomescape.util.ThemeFixture
import java.time.LocalDate
class ReservationQueryServiceTest : FunSpec({
val reservationFinder: ReservationFinder = mockk()
val reservationFindService = ReservationFindService(reservationFinder)
context("findReservations") {
test("정상 응답") {
val confirmedReservations = listOf(
ReservationFixture.create(status = ReservationStatus.CONFIRMED),
ReservationFixture.create(status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
)
every {
reservationFinder.findAllByStatuses(*ReservationStatus.confirmedStatus())
} returns confirmedReservations
val response = reservationFindService.findReservations()
assertSoftly(response.reservations) {
this shouldHaveSize 2
}
}
}
context("findAllWaiting") {
test("정상 응답") {
val waitingReservations = listOf(
ReservationFixture.create(status = ReservationStatus.WAITING),
ReservationFixture.create(status = ReservationStatus.WAITING)
)
every {
reservationFinder.findAllByStatuses(ReservationStatus.WAITING)
} returns waitingReservations
val response = reservationFindService.findAllWaiting()
assertSoftly(response.reservations) {
this shouldHaveSize 2
}
}
}
context("findReservationsByMemberId") {
val memberId = 1L
test("정상 응답") {
val myReservations = listOf<MyReservationRetrieveResponse>(mockk(), mockk())
every {
reservationFinder.findAllByMemberId(memberId)
} returns myReservations
val response = reservationFindService.findReservationsByMemberId(memberId)
response.reservations shouldHaveSize 2
}
}
context("searchReservations") {
val themeId = 1L
val memberId = 1L
val startFrom = LocalDate.now()
val endAt = LocalDate.now().plusDays(1)
test("정상 응답") {
val searchedReservations = listOf(
ReservationFixture.create(
theme = ThemeFixture.create(themeId),
member = MemberFixture.create(memberId),
date = startFrom
)
)
every {
reservationFinder.searchReservations(themeId, memberId, startFrom, endAt)
} returns searchedReservations
val response = reservationFindService.searchReservations(themeId, memberId, startFrom, endAt)
assertSoftly(response.reservations) {
this shouldHaveSize 1
this[0].theme.id shouldBe themeId
this[0].member.id shouldBe memberId
this[0].date shouldBe startFrom
}
}
test("종료 날짜가 시작 날짜 이전이면 예외 응답") {
val invalidEndAt = startFrom.minusDays(1)
every {
reservationFinder.searchReservations(themeId, memberId, startFrom, invalidEndAt)
} throws ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE)
shouldThrow<ReservationException> {
reservationFindService.searchReservations(themeId, memberId, startFrom, invalidEndAt)
}.also {
it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE
}
}
}
})

View File

@ -1,287 +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 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.TsidFactory
import roomescape.util.TimeFixture
import java.time.LocalDate
import java.time.LocalTime
class ReservationServiceTest : FunSpec({
val reservationRepository: ReservationRepository = mockk()
val timeService: TimeService = mockk()
val memberService: MemberService = mockk()
val themeService: ThemeService = mockk()
val reservationService = ReservationService(
TsidFactory,
reservationRepository,
timeService,
memberService,
themeService
)
context("예약을 추가할 때") {
test("이미 예약이 있으면 예외를 던진다.") {
every {
reservationRepository.exists(any())
} returns true
val reservationRequest = ReservationFixture.createRequest()
shouldThrow<ReservationException> {
reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also {
it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED
}
}
context("날짜, 시간이 잘못 입력되면 예외를 던진다.") {
every {
reservationRepository.exists(any())
} returns false
every {
themeService.findById(any())
} returns mockk()
every {
memberService.findById(any())
} returns mockk()
test("지난 날짜이면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now().minusDays(1)
)
every {
timeService.findById(any())
} returns TimeFixture.create()
shouldThrow<ReservationException> {
reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also {
it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME
}
}
test("지난 시간이면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now(),
)
every {
timeService.findById(reservationRequest.timeId)
} returns TimeFixture.create(
startAt = LocalTime.now().minusMinutes(1)
)
shouldThrow<ReservationException> {
reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also {
it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME
}
}
}
}
context("예약 대기를 걸 때") {
test("이미 예약한 회원이 같은 날짜와 테마로 대기를 걸면 예외를 던진다.") {
val reservationRequest = ReservationFixture.createRequest().copy(
date = LocalDate.now(),
themeId = 1L,
timeId = 1L,
)
every {
reservationRepository.exists(any())
} returns true
shouldThrow<ReservationException> {
val waitingRequest = ReservationFixture.createWaitingRequest(
date = reservationRequest.date,
themeId = reservationRequest.themeId,
timeId = reservationRequest.timeId
)
reservationService.createWaiting(waitingRequest, 1L)
}.also {
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
}
}
}
context("예약을 조회할 때") {
test("종료 날짜가 시작 날짜보다 이전이면 예외를 던진다.") {
val startFrom = LocalDate.now()
val endAt = startFrom.minusDays(1)
shouldThrow<ReservationException> {
reservationService.searchReservations(
null,
null,
startFrom,
endAt
)
}.also {
it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE
}
}
}
context("대기중인 예약을 승인할 때") {
test("관리자가 아니면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.MEMBER)
every {
memberService.findById(any())
} returns member
shouldThrow<ReservationException> {
reservationService.confirmWaiting(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(any())
} returns member
every {
reservationRepository.isExistConfirmedReservation(reservationId)
} returns true
shouldThrow<ReservationException> {
reservationService.confirmWaiting(reservationId, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS
}
}
}
context("대기중인 예약을 거절할 때") {
test("관리자가 아니면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.MEMBER)
every {
memberService.findById(any())
} returns member
shouldThrow<ReservationException> {
reservationService.rejectWaiting(1L, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION
}
}
test("예약을 찾을 수 없으면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.ADMIN)
val reservationId = 1L
every {
memberService.findById(member.id!!)
} returns member
every {
reservationRepository.findByIdOrNull(reservationId)
} returns null
shouldThrow<ReservationException> {
reservationService.rejectWaiting(reservationId, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND
}
}
test("이미 확정된 예약이면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.ADMIN)
val reservation = ReservationFixture.create(
id = 1L,
status = ReservationStatus.CONFIRMED
)
every {
memberService.findById(member.id!!)
} returns member
every {
reservationRepository.findByIdOrNull(reservation.id!!)
} returns reservation
shouldThrow<ReservationException> {
reservationService.rejectWaiting(reservation.id!!, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED
}
}
}
})

View File

@ -13,16 +13,16 @@ import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.toCreateResponse import roomescape.payment.web.toCreateResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.ReservationCreateResponse
import roomescape.reservation.web.ReservationCreateWithPaymentRequest import roomescape.reservation.web.ReservationCreateWithPaymentRequest
import roomescape.reservation.web.ReservationRetrieveResponse
import roomescape.util.* import roomescape.util.*
class ReservationWithPaymentServiceTest : FunSpec({ class ReservationWithPaymentServiceTest : FunSpec({
val reservationService: ReservationService = mockk() val reservationService: ReservationWriteService = mockk()
val paymentService: PaymentService = mockk() val paymentService: PaymentService = mockk()
val reservationWithPaymentService = ReservationWithPaymentService( val reservationWithPaymentService = ReservationWithPaymentService(
reservationService = reservationService, reservationWriteService = reservationService,
paymentService = paymentService paymentService = paymentService
) )
@ -48,16 +48,16 @@ class ReservationWithPaymentServiceTest : FunSpec({
context("addReservationWithPayment") { context("addReservationWithPayment") {
test("예약 및 결제 정보를 저장한다.") { test("예약 및 결제 정보를 저장한다.") {
every { every {
reservationService.createConfirmedReservation(reservationCreateWithPaymentRequest, memberId) reservationService.createReservationWithPayment(reservationCreateWithPaymentRequest, memberId)
} returns reservationEntity } returns reservationEntity
every { every {
paymentService.createPayment(paymentApproveResponse, reservationEntity) paymentService.createPayment(paymentApproveResponse, reservationEntity)
} returns paymentEntity.toCreateResponse() } returns paymentEntity.toCreateResponse()
val result: ReservationRetrieveResponse = reservationWithPaymentService.createReservationAndPayment( val result: ReservationCreateResponse = reservationWithPaymentService.createReservationAndPayment(
request = reservationCreateWithPaymentRequest, request = reservationCreateWithPaymentRequest,
paymentInfo = paymentApproveResponse, approvedPaymentInfo = paymentApproveResponse,
memberId = memberId memberId = memberId
) )
@ -81,7 +81,7 @@ class ReservationWithPaymentServiceTest : FunSpec({
) )
every { every {
paymentService.createCanceledPaymentByReservationId(reservationEntity.id!!) paymentService.createCanceledPayment(reservationEntity.id!!)
} returns paymentCancelRequest } returns paymentCancelRequest
every { every {
@ -100,7 +100,7 @@ class ReservationWithPaymentServiceTest : FunSpec({
context("isNotPaidReservation") { context("isNotPaidReservation") {
test("결제된 예약이면 true를 반환한다.") { test("결제된 예약이면 true를 반환한다.") {
every { every {
paymentService.isReservationPaid(reservationEntity.id!!) paymentService.existsByReservationId(reservationEntity.id!!)
} returns false } returns false
val result: Boolean = reservationWithPaymentService.isNotPaidReservation(reservationEntity.id!!) val result: Boolean = reservationWithPaymentService.isNotPaidReservation(reservationEntity.id!!)
@ -110,7 +110,7 @@ class ReservationWithPaymentServiceTest : FunSpec({
test("결제되지 않은 예약이면 false를 반환한다.") { test("결제되지 않은 예약이면 false를 반환한다.") {
every { every {
paymentService.isReservationPaid(reservationEntity.id!!) paymentService.existsByReservationId(reservationEntity.id!!)
} returns true } returns true
val result: Boolean = reservationWithPaymentService.isNotPaidReservation(reservationEntity.id!!) val result: Boolean = reservationWithPaymentService.isNotPaidReservation(reservationEntity.id!!)

View File

@ -0,0 +1,64 @@
package roomescape.reservation.implement
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 io.mockk.spyk
import io.mockk.verify
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
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.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import java.time.LocalDate
class ReservationFinderTest : FunSpec({
val reservationRepository: ReservationRepository = mockk()
val reservationValidator = ReservationValidator(reservationRepository)
val reservationFinder = ReservationFinder(reservationRepository, reservationValidator)
context("findById") {
val reservationId = 1L
test("동일한 ID인 시간을 찾아 응답한다.") {
every {
reservationRepository.findByIdOrNull(reservationId)
} returns mockk()
reservationFinder.findById(reservationId)
verify(exactly = 1) {
reservationRepository.findByIdOrNull(reservationId)
}
}
test("동일한 ID인 시간이 없으면 실패한다.") {
every {
reservationRepository.findByIdOrNull(reservationId)
} returns null
shouldThrow<ReservationException> {
reservationFinder.findById(reservationId)
}.also {
it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND
}
}
}
context("searchReservations") {
test("시작 날짜가 종료 날짜 이전이면 실패한다.") {
shouldThrow<ReservationException> {
reservationFinder.searchReservations(1L, 1L, LocalDate.now(), LocalDate.now().minusDays(1))
}.also {
it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE
}
}
}
})

View File

@ -0,0 +1,170 @@
package roomescape.reservation.implement
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.every
import io.mockk.mockk
import org.springframework.data.jpa.domain.Specification
import roomescape.member.infrastructure.persistence.Role
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.ReservationStatus
import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture
import roomescape.util.ThemeFixture
import roomescape.util.TimeFixture
import java.time.LocalDate
import java.time.LocalTime
class ReservationValidatorTest : FunSpec({
val reservationRepository: ReservationRepository = mockk()
val reservationValidator = ReservationValidator(reservationRepository)
context("validateIsNotPast") {
val today = LocalDate.now()
val now = LocalTime.now()
test("입력된 날짜가 오늘 이전이면 예외를 던진다.") {
val requestDate = today.minusDays(1)
shouldThrow<ReservationException> {
reservationValidator.validateIsPast(requestDate, now)
}.also {
it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME
}
}
test("오늘 날짜라도 시간이 지났다면 예외를 던진다.") {
val requestTime = now.minusMinutes(1)
shouldThrow<ReservationException> {
reservationValidator.validateIsPast(today, requestTime)
}.also {
it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME
}
}
}
context("validateSearchDateRange") {
test("시작 날짜만 입력되면 종료한다.") {
shouldNotThrow<ReservationException> {
reservationValidator.validateSearchDateRange(LocalDate.now(), null)
}
}
test("종료 날짜만 입력되면 종료한다.") {
shouldNotThrow<ReservationException> {
reservationValidator.validateSearchDateRange(null, LocalDate.now())
}
}
test("두 날짜가 같으면 종료한다.") {
shouldNotThrow<ReservationException> {
reservationValidator.validateSearchDateRange(LocalDate.now(), LocalDate.now())
}
}
test("종료 날짜가 시작 날짜 이전이면 예외를 던진다.") {
shouldThrow<ReservationException> {
reservationValidator.validateSearchDateRange(LocalDate.now(), LocalDate.now().minusDays(1))
}.also {
it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE
}
}
}
context("validateIsAlreadyExists") {
test("동일한 날짜, 시간, 테마를 가지는 예약이 있으면 예외를 던진다.") {
every {
reservationRepository.exists(any<Specification<ReservationEntity>>())
} returns true
shouldThrow<ReservationException> {
reservationValidator.validateIsAlreadyExists(
LocalDate.now(),
TimeFixture.create(),
ThemeFixture.create()
)
}.also {
it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED
}
}
}
context("validateMemberAlreadyReserve") {
test("회원이 동일한 날짜, 시간, 테마인 예약(대기)를 이미 했다면 예외를 던진다.") {
every {
reservationRepository.exists(any<Specification<ReservationEntity>>())
} returns true
shouldThrow<ReservationException> {
reservationValidator.validateMemberAlreadyReserve(1L, 1L, LocalDate.now(), 1L)
}.also {
it.errorCode shouldBe ReservationErrorCode.ALREADY_RESERVE
}
}
}
context("validateIsWaiting") {
test("예약 상태가 WAITING이 아니면 예외를 던진다.") {
ReservationStatus.confirmedStatus().forEach { status ->
shouldThrow<ReservationException> {
val reservation = ReservationFixture.create(status = status)
reservationValidator.validateIsWaiting(reservation)
}.also {
it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED
}
}
}
}
context("validateCreateAuthority") {
test("관리자가 아니면 예외를 던진다.") {
shouldThrow<ReservationException> {
reservationValidator.validateCreateAuthority(MemberFixture.user())
}.also {
it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION
}
}
}
context("validateDeleteAuthority") {
test("입력된 회원이 관리자이면 종료한다.") {
shouldNotThrow<ReservationException> {
reservationValidator.validateDeleteAuthority(mockk(), MemberFixture.admin())
}
}
test("입력된 회원이 관리자가 아니고, 예약한 회원과 다른 회원이면 예외를 던진다.") {
shouldThrow<ReservationException> {
reservationValidator.validateDeleteAuthority(
ReservationFixture.create(member = MemberFixture.create(id = 1L)),
MemberFixture.create(id = 2L, role = Role.MEMBER)
)
}.also {
it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER
}
}
}
context("validateAlreadyConfirmed") {
val reservationId = 1L
test("입력된 ID의 예약과 동일한 날짜, 시간, 테마를 가지는 다른 확정 예약이 있으면 예외를 던진다.") {
every {
reservationRepository.isExistConfirmedReservation(reservationId)
} returns true
shouldThrow<ReservationException> {
reservationValidator.validateAlreadyConfirmed(reservationId)
}.also {
it.errorCode shouldBe ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS
}
}
}
})

View File

@ -0,0 +1,246 @@
package roomescape.reservation.implement
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 roomescape.member.exception.MemberErrorCode
import roomescape.member.exception.MemberException
import roomescape.member.implement.MemberFinder
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.ReservationStatus
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.implement.ThemeFinder
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.implement.TimeFinder
import roomescape.util.MemberFixture
import roomescape.util.ThemeFixture
import roomescape.util.TimeFixture
import roomescape.util.TsidFactory
import java.time.LocalDate
import java.time.LocalTime
class ReservationWriterTest : FunSpec({
val reservationValidator: ReservationValidator = mockk()
val reservationRepository: ReservationRepository = mockk()
val memberFinder: MemberFinder = mockk()
val timeFinder: TimeFinder = mockk()
val themeFinder: ThemeFinder = mockk()
val reservationWriter = ReservationWriter(
reservationValidator, reservationRepository, memberFinder, timeFinder, themeFinder, TsidFactory
)
context("create") {
val today = LocalDate.now()
val timeId = 1L
val themeId = 1L
val memberId = 1L
val status = ReservationStatus.CONFIRMED
val requesterId = 1L
test("시간을 찾을 수 없으면 실패한다.") {
every {
timeFinder.findById(any())
} throws TimeException(TimeErrorCode.TIME_NOT_FOUND)
shouldThrow<TimeException> {
reservationWriter.create(today, timeId, themeId, memberId, status, requesterId)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_NOT_FOUND
}
}
test("이전 날짜이면 실패한다.") {
every {
timeFinder.findById(timeId)
} returns TimeFixture.create(id = timeId, startAt = LocalTime.now().plusHours(1))
every {
reservationValidator.validateIsPast(any(), any())
} throws ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME)
shouldThrow<ReservationException> {
reservationWriter.create(today.minusDays(1), timeId, themeId, memberId, status, requesterId)
}.also {
it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME
}
}
test("테마를 찾을 수 없으면 실패한다.") {
every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId)
every { reservationValidator.validateIsPast(any(), any()) } returns Unit
every {
themeFinder.findById(themeId)
} throws ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
shouldThrow<ThemeException> {
reservationWriter.create(today.plusDays(1), timeId, themeId, memberId, status, requesterId)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND
}
}
test("회원을 찾을 수 없으면 실패한다.") {
every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId)
every { reservationValidator.validateIsPast(any(), any()) } returns Unit
every { themeFinder.findById(themeId) } returns ThemeFixture.create(id = themeId)
every {
memberFinder.findById(memberId)
} throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
shouldThrow<MemberException> {
reservationWriter.create(today.plusDays(1), timeId, themeId, memberId, status, requesterId)
}.also {
it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND
}
}
test("이미 예약이 있는 회원이 대기를 추가하면 실패한다.") {
every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId)
every { reservationValidator.validateIsPast(any(), any()) } returns Unit
every { themeFinder.findById(themeId) } returns ThemeFixture.create(id = themeId)
every { memberFinder.findById(memberId) } returns MemberFixture.create(id = memberId)
every {
reservationValidator.validateMemberAlreadyReserve(themeId, timeId, today, memberId)
} throws ReservationException(ReservationErrorCode.ALREADY_RESERVE)
shouldThrow<ReservationException> {
reservationWriter.create(today, timeId, themeId, memberId, status = ReservationStatus.WAITING, memberId)
}.also {
it.errorCode shouldBe ReservationErrorCode.ALREADY_RESERVE
}
}
test("동일한 날짜, 시간, 테마인 예약이 이미 있으면 실패한다.") {
every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId)
every { reservationValidator.validateIsPast(any(), any()) } returns Unit
every { themeFinder.findById(themeId) } returns ThemeFixture.create(id = themeId)
every { memberFinder.findById(memberId) } returns MemberFixture.create(id = memberId)
every {
reservationValidator.validateIsAlreadyExists(today, any(), any())
} throws ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED)
shouldThrow<ReservationException> {
reservationWriter.create(today, timeId, themeId, memberId, status, memberId)
}.also {
it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED
}
}
test("예약하려는 회원과 신청한 회원이 다를 때, 신청한 회원을 찾을 수 없으면 실패한다.") {
every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId)
every { reservationValidator.validateIsPast(any(), any()) } returns Unit
every { themeFinder.findById(themeId) } returns ThemeFixture.create(id = themeId)
every { memberFinder.findById(memberId) } returns MemberFixture.create(id = memberId)
every { reservationValidator.validateIsAlreadyExists(today, any(), any()) } returns Unit
every {
memberFinder.findById(memberId + 1)
} throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
shouldThrow<MemberException> {
reservationWriter.create(today, timeId, themeId, memberId = memberId, status, requesterId = (memberId + 1))
}.also {
it.errorCode shouldBe MemberErrorCode.MEMBER_NOT_FOUND
}
}
test("예약하려는 회원과 신청한 회원이 다를 때, 신청한 회원이 관리자가 아니면 실패한다.") {
every { timeFinder.findById(timeId) } returns TimeFixture.create(id = timeId)
every { reservationValidator.validateIsPast(any(), any()) } returns Unit
every { themeFinder.findById(themeId) } returns ThemeFixture.create(id = themeId)
every { memberFinder.findById(memberId) } returns MemberFixture.create(id = memberId)
every { reservationValidator.validateIsAlreadyExists(today, any(), any()) } returns Unit
every {
memberFinder.findById(memberId + 1)
} returns MemberFixture.create(id = memberId + 1)
every {
reservationValidator.validateCreateAuthority(any())
} throws ReservationException(ReservationErrorCode.NO_PERMISSION)
shouldThrow<ReservationException> {
reservationWriter.create(today, timeId, themeId, memberId = memberId, status, requesterId = (memberId + 1))
}.also {
it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION
}
}
}
context("deleteWaiting") {
val reservation: ReservationEntity = mockk()
val requesterId = 1L
test("대기 상태가 아니면 실패한다.") {
every {
reservationValidator.validateIsWaiting(any())
} throws ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
shouldThrow<ReservationException> {
reservationWriter.deleteWaiting(reservation, requesterId)
}.also {
it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED
}
}
test("삭제하려는 회원이 관리자가 아니고, 예약한 회원과 다르면 실패한다.") {
every { reservationValidator.validateIsWaiting(any()) } returns Unit
every {
reservationValidator.validateDeleteAuthority(any(), any())
} throws ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
shouldThrow<ReservationException> {
reservationWriter.deleteWaiting(reservation, requesterId)
}.also {
it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER
}
}
}
context("deleteConfirm") {
val reservation: ReservationEntity = mockk()
val requesterId = 1L
test("삭제하려는 회원이 관리자가 아니고, 예약한 회원과 다르면 실패한다.") {
every { reservationValidator.validateIsWaiting(any()) } returns Unit
every {
reservationValidator.validateDeleteAuthority(any(), any())
} throws ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
shouldThrow<ReservationException> {
reservationWriter.deleteConfirmed(reservation, requesterId)
}.also {
it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER
}
}
}
context("confirm") {
val reservationId = 1L
test("승인하려는 대기와 같은 날짜,시간,테마를 가진 확정 예약이 있으면 실패한다.") {
every {
reservationValidator.validateAlreadyConfirmed(reservationId)
} throws ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS)
shouldThrow<ReservationException> {
reservationWriter.confirm(reservationId)
}.also {
it.errorCode shouldBe ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS
}
}
}
})

View File

@ -81,6 +81,21 @@ class ReservationSearchSpecificationTest(
} }
} }
"여러 상태를 입력받아 같은 상태안 예약을 조회한다." {
val spec = ReservationSearchSpecification()
.status(
ReservationStatus.CONFIRMED,
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED,
ReservationStatus.WAITING
).build()
val results: List<ReservationEntity> = reservationRepository.findAll(spec)
assertSoftly(results) {
this shouldHaveSize reservationRepository.findAll().size
}
}
"확정 상태인 예약을 조회한다" { "확정 상태인 예약을 조회한다" {
val spec = ReservationSearchSpecification() val spec = ReservationSearchSpecification()
.confirmed() .confirmed()

View File

@ -1,87 +1,108 @@
package roomescape.theme.business package roomescape.theme.business
import io.kotest.assertions.assertSoftly import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldNotThrow
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.*
import io.mockk.mockk
import org.springframework.data.repository.findByIdOrNull
import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException import roomescape.theme.exception.ThemeException
import roomescape.theme.implement.ThemeFinder
import roomescape.theme.implement.ThemeWriter
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.ThemeCreateRequest import roomescape.theme.web.ThemeCreateRequest
import roomescape.theme.web.ThemeRetrieveResponse import roomescape.theme.web.ThemeCreateResponse
import roomescape.util.TsidFactory
import roomescape.util.ThemeFixture import roomescape.util.ThemeFixture
import java.time.LocalDate
class ThemeServiceTest : FunSpec({ class ThemeServiceTest : FunSpec({
val themeFinder: ThemeFinder = mockk()
val themeWriter: ThemeWriter = mockk()
val themeRepository: ThemeRepository = mockk() val themeService = ThemeService(themeFinder, themeWriter)
val themeService = ThemeService(TsidFactory, themeRepository)
context("findThemeById") { context("findThemeById") {
val themeId = 1L val themeId = 1L
test("조회 성공") {
test("정상 응답") {
val theme: ThemeEntity = ThemeFixture.create(id = themeId) val theme: ThemeEntity = ThemeFixture.create(id = themeId)
every { every {
themeRepository.findByIdOrNull(themeId) themeFinder.findById(themeId)
} returns theme } returns theme
theme.id shouldBe themeId theme.id shouldBe themeId
} }
test("ID로 테마를 찾을 수 없으면 400 예외를 던진다.") { test("테마를 찾을 수 없으면 예외 응답") {
every { every {
themeRepository.findByIdOrNull(themeId) themeFinder.findById(themeId)
} returns null } throws ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
val exception = shouldThrow<ThemeException> { shouldThrow<ThemeException> {
themeService.findById(themeId) themeService.findById(themeId)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND
} }
exception.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND
} }
} }
context("findAllThemes") { context("findThemes") {
test("모든 테마를 조회한다.") { test("정상 응답") {
val themes = listOf(ThemeFixture.create(id = 1, name = "t1"), ThemeFixture.create(id = 2, name = "t2")) val themes = listOf(
ThemeFixture.create(id = 1, name = "t1"),
ThemeFixture.create(id = 2, name = "t2")
)
every { every {
themeRepository.findAll() themeFinder.findAll()
} returns themes } returns themes
assertSoftly(themeService.findThemes()) { val response = themeService.findThemes()
this.themes.size shouldBe themes.size
this.themes[0].name shouldBe "t1" assertSoftly(response.themes) {
this.themes[1].name shouldBe "t2" this.size shouldBe themes.size
it.map { theme -> theme.name } shouldContainExactly themes.map { theme -> theme.name }
} }
} }
} }
context("save") { context("findMostReservedThemes") {
test("7일 전 부터 1일 전 까지 조회") {
val count = 10
val startFrom = slot<LocalDate>()
val endAt = slot<LocalDate>()
every {
themeFinder.findMostReservedThemes(count, capture(startFrom), capture(endAt))
} returns emptyList()
themeService.findMostReservedThemes(count)
startFrom.captured shouldBe LocalDate.now().minusDays(7)
endAt.captured shouldBe LocalDate.now().minusDays(1)
}
}
context("createTheme") {
val request = ThemeCreateRequest( val request = ThemeCreateRequest(
name = "New Theme", name = "New Theme",
description = "Description", description = "Description",
thumbnail = "http://example.com/thumbnail.jpg" thumbnail = "http://example.com/thumbnail.jpg"
) )
test("저장 성공") { test("정상 저장") {
every { every {
themeRepository.existsByName(request.name) themeWriter.create(request.name, request.description, request.thumbnail)
} returns false
every {
themeRepository.save(any())
} returns ThemeFixture.create( } returns ThemeFixture.create(
id = 1L, id = 1,
name = request.name, name = request.name,
description = request.description, description = request.description,
thumbnail = request.thumbnail thumbnail = request.thumbnail
) )
val response: ThemeRetrieveResponse = themeService.createTheme(request) val response: ThemeCreateResponse = themeService.createTheme(request)
assertSoftly(response) { assertSoftly(response) {
this.id shouldBe 1L this.id shouldBe 1L
@ -91,32 +112,51 @@ class ThemeServiceTest : FunSpec({
} }
} }
test("테마 이름이 중복되면 409 예외를 던진다.") { test("중복된 이름이 있으면 예외 응답") {
every { every {
themeRepository.existsByName(request.name) themeWriter.create(request.name, request.description, request.thumbnail)
} returns true } throws ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
val exception = shouldThrow<ThemeException> { shouldThrow<ThemeException> {
themeService.createTheme(request) themeService.createTheme(request)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_NAME_DUPLICATED
} }
exception.errorCode shouldBe ThemeErrorCode.THEME_NAME_DUPLICATED
} }
} }
context("deleteById") { context("deleteById") {
test("이미 예약 중인 테마라면 409 예외를 던진다.") { val themeId = 1L
val themeId = 1L val theme: ThemeEntity = ThemeFixture.create(id = themeId)
every { test("정상 응답") {
themeRepository.isReservedTheme(themeId) every { themeFinder.findById(themeId) } returns theme
} returns true every { themeWriter.delete(theme) } just Runs
val exception = shouldThrow<ThemeException> { shouldNotThrow<ThemeException> {
themeService.deleteTheme(themeId) themeService.deleteTheme(themeId)
} }
}
exception.errorCode shouldBe ThemeErrorCode.THEME_ALREADY_RESERVED test("테마를 찾을 수 없으면 예외 응답") {
every { themeFinder.findById(themeId) } throws ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
shouldThrow<ThemeException> {
themeService.deleteTheme(themeId)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND
}
}
test("예약이 있는 테마이면 예외 응답") {
every { themeFinder.findById(themeId) } returns theme
every { themeWriter.delete(theme) } throws ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED)
shouldThrow<ThemeException> {
themeService.deleteTheme(themeId)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_ALREADY_RESERVED
}
} }
} }
}) })

View File

@ -0,0 +1,76 @@
package roomescape.theme.implement
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.springframework.data.repository.findByIdOrNull
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.util.ThemeFixture
import java.time.LocalDate
class ThemeFinderTest : FunSpec({
val themeRepository: ThemeRepository = mockk()
val themeFinder = ThemeFinder(themeRepository)
context("findAll") {
test("모든 테마를 조회한다.") {
every {
themeRepository.findAll()
} returns listOf(mockk(), mockk(), mockk())
themeRepository.findAll() shouldHaveSize 3
}
}
context("findById") {
val timeId = 1L
test("동일한 ID인 테마를 찾아 응답한다.") {
every {
themeRepository.findByIdOrNull(timeId)
} returns mockk()
themeFinder.findById(timeId)
verify(exactly = 1) {
themeRepository.findByIdOrNull(timeId)
}
}
test("동일한 ID인 테마가 없으면 실패한다.") {
every {
themeRepository.findByIdOrNull(timeId)
} returns null
shouldThrow<ThemeException> {
themeFinder.findById(timeId)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND
}
}
}
context("findMostReservedThemes") {
test("입력된 개수보다 조회된 개수가 작으면 조회된 개수만큼 반환한다.") {
val count = 10
val startFrom = LocalDate.now().minusDays(7)
val endAt = LocalDate.now().minusDays(1)
every {
themeRepository.findPopularThemes(startFrom, endAt, count)
} returns listOf(
ThemeFixture.create(id = 1, name = "name1"),
ThemeFixture.create(id = 2, name = "name2"),
ThemeFixture.create(id = 3, name = "name3"),
)
themeFinder.findMostReservedThemes(count, startFrom, endAt) shouldHaveSize 3
}
}
})

View File

@ -0,0 +1,79 @@
package roomescape.theme.implement
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.every
import io.mockk.mockk
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.util.ThemeFixture
class ThemeValidatorTest : FunSpec({
val themeRepository: ThemeRepository = mockk()
val themeValidator = ThemeValidator(themeRepository)
context("validateNameAlreadyExists") {
val name = "name"
test("같은 이름을 가진 테마가 있으면 예외를 던진다.") {
every {
themeRepository.existsByName(name)
} returns true
shouldThrow<ThemeException> {
themeValidator.validateNameAlreadyExists(name)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_NAME_DUPLICATED
}
}
test("같은 이름을 가진 테마가 없으면 종료한다.") {
every {
themeRepository.existsByName(name)
} returns false
shouldNotThrow<ThemeException> {
themeValidator.validateNameAlreadyExists(name)
}
}
}
context("validateIsReserved") {
test("입력된 id가 null 이면 예외를 던진다.") {
shouldThrow<ThemeException> {
themeValidator.validateIsReserved(ThemeFixture.create(id = null))
}.also {
it.errorCode shouldBe ThemeErrorCode.INVALID_REQUEST_VALUE
}
}
val theme: ThemeEntity = ThemeFixture.create(id = 1L, name = "name")
test("예약이 있는 테마이면 예외를 던진다.") {
every {
themeRepository.isReservedTheme(theme.id!!)
} returns true
shouldThrow<ThemeException> {
themeValidator.validateIsReserved(theme)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_ALREADY_RESERVED
}
}
test("예약이 없는 테마이면 종료한다.") {
every {
themeRepository.isReservedTheme(theme.id!!)
} returns false
shouldNotThrow<ThemeException> {
themeValidator.validateIsReserved(theme)
}
}
}
})

View File

@ -0,0 +1,86 @@
package roomescape.theme.implement
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.*
import roomescape.common.config.next
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.util.ThemeFixture
import roomescape.util.TsidFactory
class ThemeWriterTest : FunSpec({
val themeValidator: ThemeValidator = mockk()
val themeRepository: ThemeRepository = mockk()
val themeWriter = ThemeWriter(themeValidator, themeRepository, TsidFactory)
context("create") {
val name = "name"
val description = "description"
val thumbnail = "thumbnail"
test("중복된 이름이 있으면 실패한다.") {
every {
themeValidator.validateNameAlreadyExists(name)
} throws ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
shouldThrow<ThemeException> {
themeWriter.create(name, description, thumbnail)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_NAME_DUPLICATED
}
}
test("중복된 이름이 없으면 저장한다.") {
every {
themeValidator.validateNameAlreadyExists(name)
} just Runs
every {
themeRepository.save(any())
} returns ThemeFixture.create(name = name, description = description, thumbnail = thumbnail)
themeWriter.create(name, description, thumbnail)
verify(exactly = 1) {
themeRepository.save(any())
}
}
}
context("delete") {
val theme: ThemeEntity = ThemeFixture.create(id = TsidFactory.next())
test("예약이 있는 테마이면 실패한다.") {
every {
themeValidator.validateIsReserved(theme)
} throws ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED)
shouldThrow<ThemeException> {
themeWriter.delete(theme)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_ALREADY_RESERVED
}
}
test("예약이 없는 테마이면 제거한다.") {
every {
themeValidator.validateIsReserved(theme)
} just Runs
every {
themeRepository.delete(theme)
} just Runs
themeWriter.delete(theme)
verify(exactly = 1) {
themeRepository.delete(theme)
}
}
}
})

View File

@ -5,7 +5,7 @@ import io.kotest.matchers.collections.shouldContainInOrder
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import jakarta.persistence.EntityManager import jakarta.persistence.EntityManager
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.theme.util.TestThemeCreateUtil import roomescape.theme.util.TestThemeDataHelper
import java.time.LocalDate import java.time.LocalDate
@DataJpaTest(showSql = false) @DataJpaTest(showSql = false)
@ -14,12 +14,13 @@ class ThemeRepositoryTest(
val entityManager: EntityManager val entityManager: EntityManager
) : FunSpec() { ) : FunSpec() {
val helper = TestThemeDataHelper(entityManager, transactionTemplate = null)
init { init {
context("findTopNThemeBetweenStartDateAndEndDate") { context("findTopNThemeBetweenStartDateAndEndDate") {
beforeTest { beforeTest {
for (i in 1..10) { for (i in 1..10) {
TestThemeCreateUtil.createThemeWithReservations( helper.createThemeWithReservations(
entityManager = entityManager,
name = "테마$i", name = "테마$i",
reservedCount = i, reservedCount = i,
date = LocalDate.now().minusDays(i.toLong()), date = LocalDate.now().minusDays(i.toLong()),
@ -27,7 +28,7 @@ class ThemeRepositoryTest(
} }
} }
test("지난 10일간 예약 수가 가장 많은 테마 5개를 조회한다.") { test("지난 10일간 예약 수가 가장 많은 테마 5개를 조회") {
themeRepository.findPopularThemes( themeRepository.findPopularThemes(
LocalDate.now().minusDays(10), LocalDate.now().minusDays(10),
LocalDate.now().minusDays(1), LocalDate.now().minusDays(1),
@ -40,7 +41,7 @@ class ThemeRepositoryTest(
} }
} }
test("8일 전부터 5일 전까지 예약 수가 가장 많은 테마 3개를 조회한다.") { test("8일 전부터 5일 전까지 예약 수가 가장 많은 테마 3개를 조회") {
themeRepository.findPopularThemes( themeRepository.findPopularThemes(
LocalDate.now().minusDays(8), LocalDate.now().minusDays(8),
LocalDate.now().minusDays(5), LocalDate.now().minusDays(5),
@ -53,9 +54,8 @@ class ThemeRepositoryTest(
} }
} }
test("예약 수가 동일하면 먼저 생성된 테마를 우선 조회한다.") { test("예약 수가 동일하면 먼저 생성된 테마를 우선 조회") {
TestThemeCreateUtil.createThemeWithReservations( helper.createThemeWithReservations(
entityManager = entityManager,
name = "테마11", name = "테마11",
reservedCount = 5, reservedCount = 5,
date = LocalDate.now().minusDays(5), date = LocalDate.now().minusDays(5),
@ -73,7 +73,7 @@ class ThemeRepositoryTest(
} }
} }
test("입력된 갯수보다 조회된 갯수가 작으면, 조회된 갯수만큼 반환한다.") { test("입력된 갯수보다 조회된 갯수가 작으면, 조회된 갯수만큼 반환") {
themeRepository.findPopularThemes( themeRepository.findPopularThemes(
LocalDate.now().minusDays(10), LocalDate.now().minusDays(10),
LocalDate.now().minusDays(6), LocalDate.now().minusDays(6),
@ -83,7 +83,7 @@ class ThemeRepositoryTest(
} }
} }
test("입력된 갯수보다 조회된 갯수가 많으면, 입력된 갯수만큼 반환한다.") { test("입력된 갯수보다 조회된 갯수가 많으면, 입력된 갯수만큼 반환") {
themeRepository.findPopularThemes( themeRepository.findPopularThemes(
LocalDate.now().minusDays(10), LocalDate.now().minusDays(10),
LocalDate.now().minusDays(1), LocalDate.now().minusDays(1),
@ -93,7 +93,7 @@ class ThemeRepositoryTest(
} }
} }
test("입력된 날짜 범위에 예약된 테마가 없을 경우 빈 리스트 반환한다.") { test("입력된 날짜 범위에 예약된 테마가 없을 경우 빈 리스트 반환") {
themeRepository.findPopularThemes( themeRepository.findPopularThemes(
LocalDate.now().plusDays(1), LocalDate.now().plusDays(1),
LocalDate.now().plusDays(10), LocalDate.now().plusDays(10),
@ -106,26 +106,24 @@ class ThemeRepositoryTest(
context("existsByName ") { context("existsByName ") {
val themeName = "test-theme" val themeName = "test-theme"
beforeTest { beforeTest {
TestThemeCreateUtil.createThemeWithReservations( helper.createThemeWithReservations(
entityManager = entityManager,
name = themeName, name = themeName,
reservedCount = 0, reservedCount = 0,
date = LocalDate.now() date = LocalDate.now()
) )
} }
test("테마 이름이 존재하면 true 반환한다.") { test("테마 이름이 존재하면 true 반환") {
themeRepository.existsByName(themeName) shouldBe true themeRepository.existsByName(themeName) shouldBe true
} }
test("테마 이름이 존재하지 않으면 false 반환한다.") { test("테마 이름이 존재하지 않으면 false 반환") {
themeRepository.existsByName(themeName.repeat(2)) shouldBe false themeRepository.existsByName(themeName.repeat(2)) shouldBe false
} }
} }
context("isReservedTheme") { context("isReservedTheme") {
test("테마가 예약 중이면 true를 반환한다.") { test("테마가 예약 중이면 true 반환") {
val theme = TestThemeCreateUtil.createThemeWithReservations( val theme = helper.createThemeWithReservations(
entityManager = entityManager,
name = "예약된 테마", name = "예약된 테마",
reservedCount = 1, reservedCount = 1,
date = LocalDate.now() date = LocalDate.now()
@ -133,9 +131,8 @@ class ThemeRepositoryTest(
themeRepository.isReservedTheme(theme.id!!) shouldBe true themeRepository.isReservedTheme(theme.id!!) shouldBe true
} }
test("테마가 예약 중이 아니면 false를 반환한다.") { test("테마가 예약 중이 아니면 false 반환") {
val theme = TestThemeCreateUtil.createThemeWithReservations( val theme = helper.createThemeWithReservations(
entityManager = entityManager,
name = "예약되지 않은 테마", name = "예약되지 않은 테마",
reservedCount = 0, reservedCount = 0,
date = LocalDate.now() date = LocalDate.now()

View File

@ -1,6 +1,7 @@
package roomescape.theme.util package roomescape.theme.util
import jakarta.persistence.EntityManager import jakarta.persistence.EntityManager
import org.springframework.transaction.support.TransactionTemplate
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
@ -12,13 +13,25 @@ import roomescape.util.TimeFixture
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
object TestThemeCreateUtil { class TestThemeDataHelper(
fun createThemeWithReservations( val entityManager: EntityManager,
entityManager: EntityManager, val transactionTemplate: TransactionTemplate?
name: String, ) {
reservedCount: Int, /**
date: LocalDate, * GET /themes/most-reserved-last-week API와 관련 Repository 테스트에 사용
): ThemeEntity { * @param name: 테마 이름
* @param reservedCount: 테마가 예약된 횟수
* @param date: reservedCount 개의 예약을 만들 사용할 날짜
*/
fun createThemeWithReservations(name: String, reservedCount: Int, date: LocalDate): ThemeEntity =
if (transactionTemplate == null) {
createAndGet(name, reservedCount, date)
} else {
transactionTemplate.execute { createAndGet(name, reservedCount, date) }!!
}
fun createAndGet(name: String, reservedCount: Int, date: LocalDate): ThemeEntity {
val themeEntity: ThemeEntity = ThemeFixture.create(name = name).also { entityManager.persist(it) } val themeEntity: ThemeEntity = ThemeFixture.create(name = name).also { entityManager.persist(it) }
val member: MemberEntity = MemberFixture.create().also { entityManager.persist(it) } val member: MemberEntity = MemberFixture.create().also { entityManager.persist(it) }
@ -32,7 +45,7 @@ object TestThemeCreateUtil {
theme = themeEntity, theme = themeEntity,
member = member, member = member,
time = time, time = time,
status = ReservationStatus.CONFIRMED status = ReservationStatus.entries.random()
).also { entityManager.persist(it) } ).also { entityManager.persist(it) }
} }

View File

@ -9,7 +9,7 @@ import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
import roomescape.theme.util.TestThemeCreateUtil import roomescape.theme.util.TestThemeDataHelper
import roomescape.util.CleanerMode import roomescape.util.CleanerMode
import roomescape.util.DatabaseCleanerExtension import roomescape.util.DatabaseCleanerExtension
import java.time.LocalDate import java.time.LocalDate
@ -20,16 +20,16 @@ class MostReservedThemeApiTest(
@LocalServerPort val port: Int, @LocalServerPort val port: Int,
val transactionTemplate: TransactionTemplate, val transactionTemplate: TransactionTemplate,
val entityManager: EntityManager, val entityManager: EntityManager,
) : FunSpec({ ) : FunSpec({ extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_SPEC)) }) {
extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_SPEC))
}) { val helper = TestThemeDataHelper(entityManager, transactionTemplate)
init { init {
beforeSpec { beforeSpec {
transactionTemplate.executeWithoutResult { transactionTemplate.executeWithoutResult {
// 지난 7일간 예약된 테마 10개 생성 // 7일 전 ~ 1일 전 예약된 테마 10개 생성
for (i in 1..10) { for (i in 1..10) {
TestThemeCreateUtil.createThemeWithReservations( helper.createThemeWithReservations(
entityManager = entityManager,
name = "테마$i", name = "테마$i",
reservedCount = 1, reservedCount = 1,
date = LocalDate.now().minusDays(Random.nextLong(1, 7)) date = LocalDate.now().minusDays(Random.nextLong(1, 7))
@ -37,16 +37,22 @@ class MostReservedThemeApiTest(
} }
// 8일 전 예약된 테마 1개 생성 // 8일 전 예약된 테마 1개 생성
TestThemeCreateUtil.createThemeWithReservations( helper.createThemeWithReservations(
entityManager = entityManager,
name = "테마11", name = "테마11",
reservedCount = 1, reservedCount = 1,
date = LocalDate.now().minusDays(8) date = LocalDate.now().minusDays(8)
) )
// 당일 예약된 테마 1개 생성
helper.createThemeWithReservations(
name = "테마12",
reservedCount = 1,
date = LocalDate.now()
)
} }
} }
context("지난 주 가장 많이 예약된 테마 API") { context("GET /themes/most-reserved-last-week") {
val endpoint = "/themes/most-reserved-last-week" val endpoint = "/themes/most-reserved-last-week"
test("count 파라미터가 없으면 10개를 반환한다") { test("count 파라미터가 없으면 10개를 반환한다") {
@ -87,8 +93,8 @@ class MostReservedThemeApiTest(
} }
test("지난 7일 동안의 예약만 집계한다") { test("지난 7일 동안의 예약만 집계한다") {
// 8일 전에 예약된 테마는 집계에서 제외되어야 한다. // beforeSpec 에서 정의한 테스트 데이터 중, 8일 전 / 당일 예약 테마는 제외되어야 한다.
val count = 11 val count = 12
Given { Given {
port(port) port(port)
param("count", count) param("count", count)

View File

@ -1,42 +1,32 @@
package roomescape.theme.web package roomescape.theme.web
import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldContainAll
import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.runs
import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.common.exception.CommonErrorCode
import roomescape.theme.business.ThemeService import roomescape.theme.business.ThemeService
import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.infrastructure.persistence.ThemeRepository import roomescape.theme.exception.ThemeException
import roomescape.util.RoomescapeApiTest import roomescape.util.RoomescapeApiTest
import roomescape.util.ThemeFixture import roomescape.util.ThemeFixture
@WebMvcTest(ThemeController::class) @WebMvcTest(ThemeController::class)
class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { class ThemeControllerTest(val mockMvc: MockMvc) : RoomescapeApiTest() {
@MockkBean
@SpykBean
private lateinit var themeService: ThemeService private lateinit var themeService: ThemeService
@MockkBean
private lateinit var themeRepository: ThemeRepository
init { init {
Given("모든 테마를 조회할 때") { Given("GET /themes 요청을") {
val endpoint = "/themes" val endpoint = "/themes"
When("로그인 상태가 아니라") { When("로그인 하지 않은 사용자가 보내") {
doNotLogin() doNotLogin()
Then("에러 응답을 받는다.") { Then("예외 응답") {
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -52,34 +42,32 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
When("로그인 상태라면") { When("로그인 상태라면") {
loginAsUser() loginAsUser()
Then("조회에 성공한다.") { Then("정상 응답") {
every { every {
themeRepository.findAll() themeService.findThemes()
} returns listOf( } returns listOf(
ThemeFixture.create(id = 1, name = "theme1"), ThemeFixture.create(id = 1, name = "theme1"),
ThemeFixture.create(id = 2, name = "theme2"), ThemeFixture.create(id = 2, name = "theme2"),
ThemeFixture.create(id = 3, name = "theme3") ThemeFixture.create(id = 3, name = "theme3")
) ).toRetrieveListResponse()
val response: ThemeRetrieveListResponse = runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { isOk() } status { isOk() }
content { content {
contentType(MediaType.APPLICATION_JSON) contentType(MediaType.APPLICATION_JSON)
jsonPath("$.data.themes[0].id") { value(1) }
jsonPath("$.data.themes[1].id") { value(2) }
jsonPath("$.data.themes[2].id") { value(3) }
} }
}.andReturn().readValue(ThemeRetrieveListResponse::class.java)
assertSoftly(response.themes) {
it.size shouldBe 3
it.map { m -> m.name } shouldContainAll listOf("theme1", "theme2", "theme3")
} }
} }
} }
} }
Given("테마를 추가할 때") { Given("POST /themes 요청을") {
val endpoint = "/themes" val endpoint = "/themes"
val request = ThemeCreateRequest( val request = ThemeCreateRequest(
name = "theme1", name = "theme1",
@ -87,9 +75,10 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
thumbnail = "http://example.com/thumbnail1.jpg" thumbnail = "http://example.com/thumbnail1.jpg"
) )
When("로그인 상태가 아니라") { When("로그인 하지 않은 사용자가 보내") {
doNotLogin() doNotLogin()
Then("에러 응답을 받는다.") {
Then("예외 응답") {
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -102,9 +91,10 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
} }
} }
When("관리자가 아닌 회원은") { When("관리자가 아닌 사용자가 보내면") {
loginAsUser() loginAsUser()
Then("에러 응답을 받는다.") {
Then("예외 응답") {
val expectedError = AuthErrorCode.ACCESS_DENIED val expectedError = AuthErrorCode.ACCESS_DENIED
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -117,15 +107,17 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
} }
} }
When("동일한 이름의 테마가 있으면") { When("관리자가 보낼 때") {
loginAsAdmin() beforeTest {
loginAsAdmin()
}
val expectedError = ThemeErrorCode.THEME_NAME_DUPLICATED Then("동일한 이름의 테마가 있으면 예외 응답") {
val expectedError = ThemeErrorCode.THEME_NAME_DUPLICATED
Then("에러 응답을 받는다.") {
every { every {
themeRepository.existsByName(request.name) themeService.createTheme(request)
} returns true } throws ThemeException(expectedError)
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -136,80 +128,70 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
jsonPath("$.code") { value(expectedError.errorCode) } jsonPath("$.code") { value(expectedError.errorCode) }
} }
} }
}
When("값이 잘못 입력되면 400 에러를 응답한다") { When("입력 값의 형식이 잘못되면 예외 응답") {
beforeTest { val request = ThemeCreateRequest(
loginAsAdmin() name = "theme1",
} description = "description1",
thumbnail = "http://example.com/thumbnail1.jpg"
)
val request = ThemeCreateRequest( fun runTest(request: ThemeCreateRequest) {
name = "theme1", val expectedError = CommonErrorCode.INVALID_INPUT_VALUE
description = "description1",
thumbnail = "http://example.com/thumbnail1.jpg"
)
fun runTest(request: ThemeCreateRequest) { runPostTest(
runPostTest( mockMvc = mockMvc,
mockMvc = mockMvc, endpoint = endpoint,
endpoint = endpoint, body = request,
body = request, ) {
) { status { isEqualTo(expectedError.httpStatus.value()) }
status { isBadRequest() } jsonPath("$.code") { value(expectedError.errorCode) }
}
}
Then("이름이 공백인 경우") {
val invalidRequest = request.copy(name = " ")
runTest(invalidRequest)
}
Then("이름이 20글자를 초과하는 경우") {
val invalidRequest = request.copy(name = "a".repeat(21))
runTest(invalidRequest)
}
Then("설명이 공백인 경우") {
val invalidRequest = request.copy(description = " ")
runTest(invalidRequest)
}
Then("설명이 100글자를 초과하는 경우") {
val invalidRequest = request.copy(description = "a".repeat(101))
runTest(invalidRequest)
}
Then("썸네일이 공백인 경우") {
val invalidRequest = request.copy(thumbnail = " ")
runTest(invalidRequest)
}
Then("썸네일이 URL 형식이 아닌 경우") {
val invalidRequest = request.copy(thumbnail = "invalid-url")
runTest(invalidRequest)
} }
} }
Then("이름이 공백인 경우") { Then("정상 응답") {
val invalidRequest = request.copy(name = " ") val theme = ThemeFixture.create(
runTest(invalidRequest) id = 1,
} name = request.name,
description = request.description,
thumbnail = request.thumbnail
)
Then("이름이 20글자를 초과하는 경우") { every {
val invalidRequest = request.copy(name = "a".repeat(21)) themeService.createTheme(request)
runTest(invalidRequest) } returns theme.toCreateResponse()
}
Then("설명이 공백인 경우") {
val invalidRequest = request.copy(description = " ")
runTest(invalidRequest)
}
Then("설명이 100글자를 초과하는 경우") {
val invalidRequest = request.copy(description = "a".repeat(101))
runTest(invalidRequest)
}
Then("썸네일이 공백인 경우") {
val invalidRequest = request.copy(thumbnail = " ")
runTest(invalidRequest)
}
Then("썸네일이 URL 형식이 아닌 경우") {
val invalidRequest = request.copy(thumbnail = "invalid-url")
runTest(invalidRequest)
}
}
When("저장에 성공하면") {
loginAsAdmin()
val theme = ThemeFixture.create(
id = 1,
name = request.name,
description = request.description,
thumbnail = request.thumbnail
)
every {
themeService.createTheme(request)
} returns ThemeRetrieveResponse(
id = theme.id!!,
name = theme.name,
description = theme.description,
thumbnail = theme.thumbnail
)
Then("201 응답을 받는다.") {
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
@ -228,13 +210,14 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
} }
} }
Given("테마를 제거할 때") { Given("DELETE /themes/{id} 요청을") {
val themeId = 1L val themeId = 1L
val endpoint = "/themes/$themeId" val endpoint = "/themes/$themeId"
When("로그인 상태가 아니라면") { When("관리자가 아닌 사용자가 보내면 예외 응답") {
doNotLogin() Then("로그인 하지 않은 경우") {
Then("에러 응답을 받는다.") { doNotLogin()
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
runDeleteTest( runDeleteTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -244,11 +227,10 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
jsonPath("$.code", equalTo(expectedError.errorCode)) jsonPath("$.code", equalTo(expectedError.errorCode))
} }
} }
}
When("관리자가 아닌 회원은") { Then("로그인은 하였으나 관리자가 아닌 경우") {
loginAsUser() loginAsUser()
Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.ACCESS_DENIED val expectedError = AuthErrorCode.ACCESS_DENIED
runDeleteTest( runDeleteTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -260,14 +242,16 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
} }
} }
When("이미 예약된 테마이면") { When("관리자가 보낼 때") {
loginAsAdmin() beforeTest {
val expectedError = ThemeErrorCode.THEME_ALREADY_RESERVED loginAsAdmin()
}
Then("에러 응답을 받는다.") { Then("이미 예약된 테마이면 예외 응답") {
val expectedError = ThemeErrorCode.THEME_ALREADY_RESERVED
every { every {
themeRepository.isReservedTheme(themeId) themeService.deleteTheme(themeId)
} returns true } throws ThemeException(expectedError)
runDeleteTest( runDeleteTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -277,20 +261,10 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
jsonPath("$.code") { value(expectedError.errorCode) } jsonPath("$.code") { value(expectedError.errorCode) }
} }
} }
}
When("정상적으로 제거되면") { Then("정상 응답") {
loginAsAdmin() every { themeService.deleteTheme(themeId) } returns Unit
every {
themeRepository.isReservedTheme(themeId)
} returns false
every {
themeRepository.deleteById(themeId)
} just runs
Then("204 응답을 받는다.") {
runDeleteTest( runDeleteTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,

View File

@ -1,38 +1,50 @@
package roomescape.time.business package roomescape.time.business
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldNotThrow import io.kotest.assertions.throwables.shouldNotThrow
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.Runs import io.mockk.Runs
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import org.springframework.data.repository.findByIdOrNull import roomescape.time.business.domain.TimeWithAvailability
import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.time.exception.TimeErrorCode import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeRepository import roomescape.time.implement.TimeFinder
import roomescape.time.implement.TimeWriter
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.web.TimeCreateRequest import roomescape.time.web.TimeCreateRequest
import roomescape.util.TsidFactory
import roomescape.util.TimeFixture import roomescape.util.TimeFixture
import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
class TimeServiceTest : FunSpec({ class TimeServiceTest : FunSpec({
val timeRepository: TimeRepository = mockk() val timeFinder: TimeFinder = mockk()
val reservationRepository: ReservationRepository = mockk() val timeWriter: TimeWriter = mockk()
val timeService = TimeService( val timeService = TimeService(timeFinder, timeWriter)
tsidFactory = TsidFactory,
timeRepository = timeRepository, context("findById") {
reservationRepository = reservationRepository val id = 1L
)
test("정상 응답") {
every {
timeFinder.findById(id)
} returns TimeFixture.create(id = id)
timeService.findById(id).id shouldBe id
}
context("findTimeById") {
test("시간을 찾을 수 없으면 예외 응답") { test("시간을 찾을 수 없으면 예외 응답") {
val id = 1L every {
timeFinder.findById(id)
every { timeRepository.findByIdOrNull(id) } returns null } throws TimeException(TimeErrorCode.TIME_NOT_FOUND)
shouldThrow<TimeException> { shouldThrow<TimeException> {
timeService.findById(id) timeService.findById(id)
@ -42,22 +54,81 @@ class TimeServiceTest : FunSpec({
} }
} }
context("findTimes") {
test("정상 응답") {
val times: List<TimeEntity> = listOf(
TimeFixture.create(startAt = LocalTime.now()),
TimeFixture.create(startAt = LocalTime.now().plusMinutes(1)),
TimeFixture.create(startAt = LocalTime.now().plusMinutes(2))
)
every {
timeFinder.findAll()
} returns times
val response = timeService.findTimes()
assertSoftly(response.times) {
it shouldHaveSize times.size
it.map { time -> time.startAt } shouldContainExactly times.map { time -> time.startAt }
}
}
}
context("findTimesWithAvailability") {
val date = LocalDate.now()
val themeId = 1L
test("정상 응답") {
val times: List<TimeWithAvailability> = listOf(
TimeWithAvailability(1, LocalTime.now(), date, themeId, true),
TimeWithAvailability(2, LocalTime.now().plusMinutes(1), date, themeId, false),
TimeWithAvailability(3, LocalTime.now().plusMinutes(2), date, themeId, true)
)
every {
timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
} returns times
val response = timeService.findTimesWithAvailability(date, themeId)
assertSoftly(response.times) {
it shouldHaveSize times.size
it.map { time -> time.isAvailable } shouldContainExactly times.map { time -> time.isReservable }
}
}
test("테마를 찾을 수 없으면 예외 응답") {
every {
timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
} throws ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
shouldThrow<ThemeException> {
timeService.findTimesWithAvailability(date, themeId)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND
}
}
}
context("createTime") { context("createTime") {
val request = TimeCreateRequest(startAt = LocalTime.of(10, 0)) val request = TimeCreateRequest(startAt = LocalTime.of(10, 0))
test("정상 저장") { test("정상 응답") {
every { timeRepository.existsByStartAt(request.startAt) } returns false val time: TimeEntity = TimeFixture.create(startAt = request.startAt)
every { timeRepository.save(any()) } returns TimeFixture.create(
id = 1L, every {
startAt = request.startAt timeWriter.create(request.startAt)
) } returns time
val response = timeService.createTime(request) val response = timeService.createTime(request)
response.id shouldBe 1L response.id shouldBe time.id
} }
test("중복된 시간이 있으면 예외 응답") { test("중복된 시간이 있으면 예외 응답") {
every { timeRepository.existsByStartAt(request.startAt) } returns true every {
timeWriter.create(request.startAt)
} throws TimeException(TimeErrorCode.TIME_DUPLICATED)
shouldThrow<TimeException> { shouldThrow<TimeException> {
timeService.createTime(request) timeService.createTime(request)
@ -67,14 +138,13 @@ class TimeServiceTest : FunSpec({
} }
} }
context("removeTimeById") { context("deleteTime") {
test("정상 제거 및 응답") { test("정상 응답") {
val id = 1L val id = 1L
val time = TimeFixture.create(id = id) val time = TimeFixture.create(id = id)
every { timeRepository.findByIdOrNull(id) } returns time every { timeFinder.findById(id) } returns time
every { reservationRepository.findAllByTime(time) } returns emptyList() every { timeWriter.delete(time) } just Runs
every { timeRepository.delete(time) } just Runs
shouldNotThrow<Exception> { shouldNotThrow<Exception> {
timeService.deleteTime(id) timeService.deleteTime(id)
@ -84,7 +154,7 @@ class TimeServiceTest : FunSpec({
test("시간을 찾을 수 없으면 예외 응답") { test("시간을 찾을 수 없으면 예외 응답") {
val id = 1L val id = 1L
every { timeRepository.findByIdOrNull(id) } returns null every { timeFinder.findById(id) } throws TimeException(TimeErrorCode.TIME_NOT_FOUND)
shouldThrow<TimeException> { shouldThrow<TimeException> {
timeService.deleteTime(id) timeService.deleteTime(id)
@ -97,9 +167,8 @@ class TimeServiceTest : FunSpec({
val id = 1L val id = 1L
val time = TimeFixture.create() val time = TimeFixture.create()
every { timeRepository.findByIdOrNull(id) } returns time every { timeFinder.findById(id) } returns time
every { timeWriter.delete(time) } throws TimeException(TimeErrorCode.TIME_ALREADY_RESERVED)
every { reservationRepository.findAllByTime(time) } returns listOf(mockk())
shouldThrow<TimeException> { shouldThrow<TimeException> {
timeService.deleteTime(id) timeService.deleteTime(id)

View File

@ -0,0 +1,109 @@
package roomescape.time.implement
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.springframework.data.repository.findByIdOrNull
import roomescape.reservation.implement.ReservationFinder
import roomescape.time.business.domain.TimeWithAvailability
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.implement.ThemeFinder
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.util.TimeFixture
import java.time.LocalDate
import java.time.LocalTime
class TimeFinderTest : FunSpec({
val timeRepository: TimeRepository = mockk()
val reservationFinder: ReservationFinder = mockk()
val themeFinder: ThemeFinder = mockk()
val timeFinder = TimeFinder(timeRepository, reservationFinder, themeFinder)
context("findAll") {
test("모든 시간을 조회한다.") {
every {
timeRepository.findAll()
} returns listOf(mockk(), mockk(), mockk())
timeFinder.findAll() shouldHaveSize 3
}
}
context("findById") {
val timeId = 1L
test("동일한 ID인 시간을 찾아 응답한다.") {
every {
timeRepository.findByIdOrNull(timeId)
} returns mockk()
timeFinder.findById(timeId)
verify(exactly = 1) {
timeRepository.findByIdOrNull(timeId)
}
}
test("동일한 ID인 시간이 없으면 실패한다.") {
every {
timeRepository.findByIdOrNull(timeId)
} returns null
shouldThrow<TimeException> {
timeFinder.findById(timeId)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_NOT_FOUND
}
}
}
context("findAllWithAvailabilityByDateAndThemeId") {
val date = LocalDate.now()
val themeId = 1L
test("테마를 찾을 수 없으면 실패한다.") {
every {
themeFinder.findById(themeId)
} throws ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
shouldThrow<ThemeException> {
timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
}.also {
it.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND
}
}
test("날짜, 테마에 맞는 예약 자체가 없으면 모든 시간이 예약 가능하다.") {
every {
themeFinder.findById(themeId)
} returns mockk()
every {
reservationFinder.findAllByDateAndTheme(date, any())
} returns emptyList()
every {
timeRepository.findAll()
} returns listOf(
TimeFixture.create(startAt = LocalTime.now()),
TimeFixture.create(startAt = LocalTime.now().plusMinutes(30))
)
val result: List<TimeWithAvailability> =
timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
assertSoftly(result) {
it shouldHaveSize 2
it.all { time -> time.isReservable }
}
}
}
})

View File

@ -0,0 +1,74 @@
package roomescape.time.implement
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.every
import io.mockk.mockk
import roomescape.reservation.implement.ReservationFinder
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.util.TimeFixture
import java.time.LocalTime
class TimeValidatorTest : FunSpec({
val timeRepository: TimeRepository = mockk()
val reservationFinder: ReservationFinder = mockk()
val timeValidator = TimeValidator(timeRepository, reservationFinder)
context("validateIsAlreadyExists") {
val startAt = LocalTime.now()
test("같은 이메일을 가진 회원이 있으면 예외를 던진다.") {
every {
timeRepository.existsByStartAt(startAt)
} returns true
shouldThrow<TimeException> {
timeValidator.validateIsAlreadyExists(startAt)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_DUPLICATED
}
}
test("같은 이메일을 가진 회원이 없으면 종료한다.") {
every {
timeRepository.existsByStartAt(startAt)
} returns false
shouldNotThrow<TimeException> {
timeValidator.validateIsAlreadyExists(startAt)
}
}
}
context("validateIsReserved") {
val time: TimeEntity = TimeFixture.create(startAt = LocalTime.now())
test("해당 시간에 예약이 있으면 예외를 던진다.") {
every {
reservationFinder.isTimeReserved(time)
} returns true
shouldThrow<TimeException> {
timeValidator.validateIsReserved(time)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_ALREADY_RESERVED
}
}
test("해당 시간에 예약이 없으면 종료한다.") {
every {
reservationFinder.isTimeReserved(time)
} returns false
shouldNotThrow<TimeException> {
timeValidator.validateIsReserved(time)
}
}
}
})

View File

@ -0,0 +1,84 @@
package roomescape.time.implement
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.*
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.util.TimeFixture
import roomescape.util.TsidFactory
import java.time.LocalTime
class TimeWriterTest : FunSpec({
val timeValidator: TimeValidator = mockk()
val timeRepository: TimeRepository = mockk()
val timeWriter = TimeWriter(timeValidator, timeRepository, TsidFactory)
context("create") {
val startAt = LocalTime.now()
test("중복된 시간이 있으면 실패한다.") {
every {
timeValidator.validateIsAlreadyExists(startAt)
} throws TimeException(TimeErrorCode.TIME_DUPLICATED)
shouldThrow<TimeException> {
timeWriter.create(startAt)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_DUPLICATED
}
}
test("중복된 시간이 없으면 저장한다.") {
every {
timeValidator.validateIsAlreadyExists(startAt)
} just Runs
every {
timeRepository.save(any())
} returns TimeFixture.create(startAt = startAt)
timeWriter.create(startAt)
verify(exactly = 1) {
timeRepository.save(any())
}
}
}
context("delete") {
val time: TimeEntity = TimeFixture.create()
test("예약이 있는 시간이면 실패한다.") {
every {
timeValidator.validateIsReserved(time)
} throws TimeException(TimeErrorCode.TIME_ALREADY_RESERVED)
shouldThrow<TimeException> {
timeWriter.delete(time)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_ALREADY_RESERVED
}
}
test("예약이 없는 시간이면 제거한다.") {
every {
timeValidator.validateIsReserved(time)
} just Runs
every {
timeRepository.delete(time)
} just Runs
timeWriter.delete(time)
verify(exactly = 1) {
timeRepository.delete(time)
}
}
}
})

View File

@ -1,62 +1,46 @@
package roomescape.time.web package roomescape.time.web
import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean
import io.kotest.assertions.assertSoftly import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.common.config.JacksonConfig import roomescape.theme.exception.ThemeErrorCode
import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.theme.exception.ThemeException
import roomescape.time.business.TimeService import roomescape.time.business.TimeService
import roomescape.time.exception.TimeErrorCode import roomescape.time.exception.TimeErrorCode
import roomescape.time.infrastructure.persistence.TimeEntity import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.util.ReservationFixture
import roomescape.util.RoomescapeApiTest import roomescape.util.RoomescapeApiTest
import roomescape.util.ThemeFixture
import roomescape.util.TimeFixture import roomescape.util.TimeFixture
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@WebMvcTest(TimeController::class) @WebMvcTest(TimeController::class)
class TimeControllerTest( class TimeControllerTest(val mockMvc: MockMvc) : RoomescapeApiTest() {
val mockMvc: MockMvc, @MockkBean
) : RoomescapeApiTest() {
@SpykBean
private lateinit var timeService: TimeService private lateinit var timeService: TimeService
@MockkBean
private lateinit var timeRepository: TimeRepository
@MockkBean
private lateinit var reservationRepository: ReservationRepository
init { init {
Given("등록된 모든 시간을 조회할 때") { Given("GET /times 요청을") {
val endpoint = "/times" val endpoint = "/times"
When("관리자인 경우") { When("관리자가 보내면") {
beforeTest { beforeTest {
loginAsAdmin() loginAsAdmin()
} }
Then("정상 응답") { Then("정상 응답") {
every { every {
timeRepository.findAll() timeService.findTimes()
} returns listOf( } returns listOf(
TimeFixture.create(id = 1L), TimeFixture.create(id = 1L),
TimeFixture.create(id = 2L) TimeFixture.create(id = 2L)
) ).toResponse()
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -72,11 +56,11 @@ class TimeControllerTest(
} }
} }
When("관리자가 아닌 경우") { When("관리자가 보내지 않았다면") {
loginAsUser() loginAsUser()
val expectedError = AuthErrorCode.ACCESS_DENIED val expectedError = AuthErrorCode.ACCESS_DENIED
Then("에러 응답을 받는다.") { Then("예외 응답") {
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
@ -85,17 +69,17 @@ class TimeControllerTest(
}.andExpect { }.andExpect {
content { content {
contentType(MediaType.APPLICATION_JSON) contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { value(expectedError.errorCode) } jsonPath("$.code") { equalTo(expectedError.errorCode) }
} }
} }
} }
} }
} }
Given("시간을 추가할 때") { Given("POST /times 요청을") {
val endpoint = "/times" val endpoint = "/times"
When("관리자인 경우") { When("관리자가 보낼 때") {
beforeTest { beforeTest {
loginAsAdmin() loginAsAdmin()
} }
@ -139,8 +123,8 @@ class TimeControllerTest(
Then("동일한 시간이 존재하면 예외 응답") { Then("동일한 시간이 존재하면 예외 응답") {
val expectedError = TimeErrorCode.TIME_DUPLICATED val expectedError = TimeErrorCode.TIME_DUPLICATED
every { every {
timeRepository.existsByStartAt(time) timeService.createTime(request)
} returns true } throws TimeException(expectedError)
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -150,16 +134,16 @@ class TimeControllerTest(
status { isEqualTo(expectedError.httpStatus.value()) } status { isEqualTo(expectedError.httpStatus.value()) }
content { content {
contentType(MediaType.APPLICATION_JSON) contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { value(expectedError.errorCode) } jsonPath("$.code") { equalTo(expectedError.errorCode) }
} }
} }
} }
} }
When("관리자가 아닌 경우") { When("관리자가 보내지 않았다면") {
loginAsUser() loginAsUser()
Then("에러 응답을 받는다.") { Then("예외 응답") {
val expectedError = AuthErrorCode.ACCESS_DENIED val expectedError = AuthErrorCode.ACCESS_DENIED
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -173,10 +157,10 @@ class TimeControllerTest(
} }
} }
Given("시간을 삭제할 때") { Given("DELETE /times/{id} 요청을") {
val endpoint = "/times/1" val endpoint = "/times/1"
When("관리자인 경우") { When("관리자가 보낼 때") {
beforeTest { beforeTest {
loginAsAdmin() loginAsAdmin()
} }
@ -197,9 +181,10 @@ class TimeControllerTest(
Then("없는 시간을 조회하면 예외 응답") { Then("없는 시간을 조회하면 예외 응답") {
val id = 1L val id = 1L
val expectedError = TimeErrorCode.TIME_NOT_FOUND val expectedError = TimeErrorCode.TIME_NOT_FOUND
every { every {
timeRepository.findByIdOrNull(id) timeService.deleteTime(id)
} returns null } throws TimeException(expectedError)
runDeleteTest( runDeleteTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -208,7 +193,7 @@ class TimeControllerTest(
status { isEqualTo(expectedError.httpStatus.value()) } status { isEqualTo(expectedError.httpStatus.value()) }
content { content {
contentType(MediaType.APPLICATION_JSON) contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { value(expectedError.errorCode) } jsonPath("$.code") { equalTo(expectedError.errorCode) }
} }
} }
} }
@ -216,13 +201,10 @@ class TimeControllerTest(
Then("예약이 있는 시간을 삭제하면 예외 응답") { Then("예약이 있는 시간을 삭제하면 예외 응답") {
val id = 1L val id = 1L
val expectedError = TimeErrorCode.TIME_ALREADY_RESERVED val expectedError = TimeErrorCode.TIME_ALREADY_RESERVED
every {
timeRepository.findByIdOrNull(id)
} returns TimeFixture.create(id = id)
every { every {
reservationRepository.findAllByTime(any()) timeService.deleteTime(id)
} returns listOf(ReservationFixture.create()) } throws TimeException(expectedError)
runDeleteTest( runDeleteTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -231,16 +213,16 @@ class TimeControllerTest(
status { isEqualTo(expectedError.httpStatus.value()) } status { isEqualTo(expectedError.httpStatus.value()) }
content { content {
contentType(MediaType.APPLICATION_JSON) contentType(MediaType.APPLICATION_JSON)
jsonPath("$.code") { value(expectedError.errorCode) } jsonPath("$.code") { equalTo(expectedError.errorCode) }
} }
} }
} }
} }
When("관리자가 아닌 경우") { When("관리자가 보내지 않았다면") {
loginAsUser() loginAsUser()
Then("에러 응답을 받는다.") { Then("예외 응답") {
val expectedError = AuthErrorCode.ACCESS_DENIED val expectedError = AuthErrorCode.ACCESS_DENIED
runDeleteTest( runDeleteTest(
mockMvc = mockMvc, mockMvc = mockMvc,
@ -253,36 +235,28 @@ class TimeControllerTest(
} }
} }
Given("날짜, 테마가 주어졌을 때") { Given("GET /times/search?date={date}&themeId={themeId} 요청을 ") {
loginAsUser()
val date: LocalDate = LocalDate.now() val date: LocalDate = LocalDate.now()
val themeId = 1L val themeId = 1L
When("저장된 예약 시간이 있으면") { When("회원이 보낼 때") {
val times: List<TimeEntity> = listOf( beforeTest {
TimeFixture.create(id = 1L, startAt = LocalTime.of(10, 0)), loginAsUser()
TimeFixture.create(id = 2L, startAt = LocalTime.of(11, 0)) }
)
every { Then("정상 응답") {
timeRepository.findAll() val response = TimeWithAvailabilityListResponse(
} returns times listOf(
TimeWithAvailabilityResponse(1L, LocalTime.of(10, 0), true),
Then("그 시간과, 해당 날짜와 테마에 대한 예약 여부가 담긴 목록을 응답") { TimeWithAvailabilityResponse(2L, LocalTime.of(10, 1), false),
TimeWithAvailabilityResponse(3L, LocalTime.of(10, 2), true)
every {
reservationRepository.findByDateAndThemeId(date, themeId)
} returns listOf(
ReservationFixture.create(
id = 1L,
date = date,
theme = ThemeFixture.create(id = themeId),
time = times[0]
) )
) )
every {
timeService.findTimesWithAvailability(date, themeId)
} returns response
val response = runGetTest( val result = runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = "/times/search?date=$date&themeId=$themeId", endpoint = "/times/search?date=$date&themeId=$themeId",
) { ) {
@ -292,15 +266,46 @@ class TimeControllerTest(
} }
}.andReturn().readValue(TimeWithAvailabilityListResponse::class.java) }.andReturn().readValue(TimeWithAvailabilityListResponse::class.java)
assertSoftly(response.times) { assertSoftly(result.times) {
this shouldHaveSize times.size this shouldHaveSize response.times.size
this[0].id shouldBe times[0].id this[0].id shouldBe response.times[0].id
this[0].isAvailable shouldBe false this[0].isAvailable shouldBe response.times[0].isAvailable
this[1].id shouldBe times[1].id this[1].id shouldBe response.times[1].id
this[1].isAvailable shouldBe true this[1].isAvailable shouldBe response.times[1].isAvailable
} }
} }
Then("테마를 찾을 수 없으면 예외 응답") {
val expectedError = ThemeErrorCode.THEME_NOT_FOUND
every {
timeService.findTimesWithAvailability(date, themeId)
} throws ThemeException(expectedError)
runGetTest(
mockMvc = mockMvc,
endpoint = "/times/search?date=$date&themeId=$themeId",
) {
status { isNotFound() }
jsonPath("$.code", equalTo(expectedError.errorCode))
}
}
}
When("비회원이 보내면") {
doNotLogin()
Then("예외 응답") {
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
runGetTest(
mockMvc = mockMvc,
endpoint = "/times/search?date=$date&themeId=$themeId",
) {
status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.code", equalTo(expectedError.errorCode))
}
}
} }
} }
} }

View File

@ -14,6 +14,7 @@ import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse import roomescape.payment.web.PaymentCancelResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.AdminReservationCreateRequest
import roomescape.reservation.web.ReservationCreateWithPaymentRequest import roomescape.reservation.web.ReservationCreateWithPaymentRequest
import roomescape.reservation.web.WaitingCreateRequest import roomescape.reservation.web.WaitingCreateRequest
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
@ -22,7 +23,6 @@ import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
val TsidFactory: TsidFactory = TsidFactory(0) val TsidFactory: TsidFactory = TsidFactory(0)
object MemberFixture { object MemberFixture {
@ -103,6 +103,18 @@ object ReservationFixture {
paymentType = paymentType paymentType = paymentType
) )
fun createAdminRequest(
date: LocalDate = LocalDate.now().plusWeeks(1),
themeId: Long = 1L,
timeId: Long = 1L,
memberId: Long = 1L
): AdminReservationCreateRequest = AdminReservationCreateRequest(
date = date,
timeId = timeId,
themeId = themeId,
memberId = memberId
)
fun createWaitingRequest( fun createWaitingRequest(
date: LocalDate = LocalDate.now().plusWeeks(1), date: LocalDate = LocalDate.now().plusWeeks(1),
themeId: Long = 1L, themeId: Long = 1L,

View File

@ -11,7 +11,6 @@ import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import import org.springframework.context.annotation.Import
import org.springframework.context.annotation.Primary import org.springframework.context.annotation.Primary
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.* import org.springframework.test.web.servlet.*
@ -22,7 +21,9 @@ import roomescape.auth.web.support.AuthInterceptor
import roomescape.auth.web.support.MemberIdResolver import roomescape.auth.web.support.MemberIdResolver
import roomescape.common.config.JacksonConfig import roomescape.common.config.JacksonConfig
import roomescape.common.log.ApiLogMessageConverter import roomescape.common.log.ApiLogMessageConverter
import roomescape.member.business.MemberService import roomescape.member.exception.MemberErrorCode
import roomescape.member.exception.MemberException
import roomescape.member.implement.MemberFinder
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.util.MemberFixture.NOT_LOGGED_IN_USERID import roomescape.util.MemberFixture.NOT_LOGGED_IN_USERID
@ -37,14 +38,14 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
@SpykBean @SpykBean
private lateinit var memberIdResolver: MemberIdResolver private lateinit var memberIdResolver: MemberIdResolver
@SpykBean @MockkBean
lateinit var memberService: MemberService private lateinit var memberRepository: MemberRepository
@SpykBean @SpykBean
lateinit var apiLogMessageConverter: ApiLogMessageConverter lateinit var apiLogMessageConverter: ApiLogMessageConverter
@MockkBean @SpykBean
lateinit var memberRepository: MemberRepository lateinit var memberFinder: MemberFinder
@MockkBean @MockkBean
lateinit var jwtHandler: JwtHandler lateinit var jwtHandler: JwtHandler
@ -96,8 +97,7 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
jwtHandler.getMemberIdFromToken(any()) jwtHandler.getMemberIdFromToken(any())
} returns admin.id!! } returns admin.id!!
every { memberRepository.existsById(admin.id!!) } returns true every { memberFinder.findById(admin.id!!) } returns admin
every { memberRepository.findByIdOrNull(admin.id!!) } returns admin
} }
fun loginAsUser() { fun loginAsUser() {
@ -105,8 +105,7 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
jwtHandler.getMemberIdFromToken(any()) jwtHandler.getMemberIdFromToken(any())
} returns user.id!! } returns user.id!!
every { memberRepository.existsById(user.id!!) } returns true every { memberFinder.findById(user.id!!) } returns user
every { memberRepository.findByIdOrNull(user.id!!) } returns user
} }
fun doNotLogin() { fun doNotLogin() {
@ -114,8 +113,9 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
jwtHandler.getMemberIdFromToken(any()) jwtHandler.getMemberIdFromToken(any())
} throws AuthException(AuthErrorCode.INVALID_TOKEN) } throws AuthException(AuthErrorCode.INVALID_TOKEN)
every { memberRepository.existsById(NOT_LOGGED_IN_USERID) } returns false every {
every { memberRepository.findByIdOrNull(NOT_LOGGED_IN_USERID) } returns null memberFinder.findById(NOT_LOGGED_IN_USERID)
} throws MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
} }
fun <T> MvcResult.readValue(valueType: Class<T>): T = this.response.contentAsString fun <T> MvcResult.readValue(valueType: Class<T>): T = this.response.contentAsString