[#41] 예약 스키마 재정의 #42

Merged
pricelees merged 41 commits from refactor/#41 into main 2025-09-09 00:43:39 +00:00
22 changed files with 0 additions and 2571 deletions
Showing only changes of commit 7c77f50e66 - Show all commits

View File

@ -1,54 +0,0 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.payment.implement.PaymentFinderV2
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.web.ReservationDetailRetrieveResponse
import roomescape.reservation.web.ReservationSummaryRetrieveListResponse
import roomescape.reservation.web.toCancelDetailResponse
import roomescape.reservation.web.toPaymentDetailResponse
import roomescape.reservation.web.toReservationDetailRetrieveResponse
import roomescape.reservation.web.toRetrieveResponse
import roomescape.reservation.web.toSummaryListResponse
private val log: KLogger = KotlinLogging.logger {}
@Service
class MyReservationFindService(
private val reservationFinder: ReservationFinder,
private val paymentFinder: PaymentFinderV2
) {
@Transactional(readOnly = true)
fun findReservationsByMemberId(memberId: Long): ReservationSummaryRetrieveListResponse {
log.debug { "[ReservationFindServiceV2.findReservationsByMemberId] 시작: memberId=$memberId" }
return reservationFinder.findAllByMemberIdV2(memberId)
.toSummaryListResponse()
.also { log.info { "[ReservationFindServiceV2.findReservationsByMemberId] ${it.reservations.size}개의 예약 조회 완료: memberId=$memberId" } }
}
@Transactional(readOnly = true)
fun showReservationDetails(reservationId: Long): ReservationDetailRetrieveResponse {
log.debug { "[ReservationFindServiceV2.showReservationDetails] 시작: reservationId=$reservationId" }
val reservation: ReservationEntity = reservationFinder.findById(reservationId)
val payment: PaymentEntityV2 = paymentFinder.findPaymentByReservationId(reservationId)
val paymentDetail: PaymentDetailEntity = paymentFinder.findPaymentDetailByPaymentId(payment.id)
val canceledPayment: CanceledPaymentEntityV2? = paymentFinder.findCanceledPaymentByPaymentIdOrNull(payment.id)
return reservation.toReservationDetailRetrieveResponse(
payment = payment.toRetrieveResponse(detail = paymentDetail.toPaymentDetailResponse()),
cancellation = canceledPayment?.toCancelDetailResponse()
).also {
log.info { "[ReservationFindServiceV2.showReservationDetails] 예약 상세 조회 완료: reservationId=$reservationId" }
}
}
}

View File

@ -1,56 +0,0 @@
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,75 +0,0 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.payment.business.PaymentService
import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.web.ReservationCreateResponse
import roomescape.reservation.web.ReservationCreateWithPaymentRequest
import roomescape.reservation.web.toCreateResponse
import java.time.OffsetDateTime
private val log = KotlinLogging.logger {}
@Service
@Transactional
class ReservationWithPaymentService(
private val reservationWriteService: ReservationWriteService,
private val paymentService: PaymentService,
) {
fun createReservationAndPayment(
request: ReservationCreateWithPaymentRequest,
approvedPaymentInfo: PaymentApproveResponse,
memberId: Long,
): ReservationCreateResponse {
log.info { "[ReservationWithPaymentService.createReservationAndPayment] 시작: memberId=$memberId, paymentInfo=$approvedPaymentInfo" }
val reservation: ReservationEntity = reservationWriteService.createReservationWithPayment(request, memberId)
.also { paymentService.createPayment(approvedPaymentInfo, it) }
return reservation.toCreateResponse()
.also { log.info { "[ReservationWithPaymentService.createReservationAndPayment] 완료: reservationId=${reservation.id}, paymentId=${it.id}" } }
}
fun createCanceledPayment(
canceledPaymentInfo: PaymentCancelResponse,
approvedAt: OffsetDateTime,
paymentKey: String,
) {
paymentService.createCanceledPayment(canceledPaymentInfo, approvedAt, paymentKey)
}
fun deleteReservationAndPayment(
reservationId: Long,
memberId: Long,
): PaymentCancelRequest {
log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 시작: reservationId=$reservationId" }
val paymentCancelRequest = paymentService.createCanceledPayment(reservationId)
reservationWriteService.deleteReservation(reservationId, memberId)
log.info { "[ReservationWithPaymentService.deleteReservationAndPayment] 완료: reservationId=$reservationId" }
return paymentCancelRequest
}
@Transactional(readOnly = true)
fun isNotPaidReservation(reservationId: Long): Boolean {
log.info { "[ReservationWithPaymentService.isNotPaidReservation] 시작: reservationId=$reservationId" }
val notPaid: Boolean = !paymentService.existsByReservationId(reservationId)
return notPaid.also {
log.info { "[ReservationWithPaymentService.isNotPaidReservation] 완료: reservationId=$reservationId, isPaid=${notPaid}" }
}
}
fun updateCanceledTime(
paymentKey: String,
canceledAt: OffsetDateTime,
) {
paymentService.updateCanceledTime(paymentKey, canceledAt)
}
}

View File

@ -1,108 +0,0 @@
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.common.util.TransactionExecutionUtil
import roomescape.payment.implement.PaymentFinderV2
import roomescape.payment.implement.PaymentRequester
import roomescape.payment.implement.PaymentWriterV2
import roomescape.payment.infrastructure.client.v2.PaymentConfirmResponse
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
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
class ReservationWithPaymentServiceV2(
private val reservationWriter: ReservationWriter,
private val reservationFinder: ReservationFinder,
private val paymentRequester: PaymentRequester,
private val paymentFinder: PaymentFinderV2,
private val paymentWriter: PaymentWriterV2,
private val transactionExecutionUtil: TransactionExecutionUtil,
) {
@Transactional
fun createPendingReservation(memberId: Long, request: ReservationCreateRequest): ReservationCreateResponseV2 {
log.info {
"[ReservationWithPaymentServiceV2.createPendingReservation] " +
"PENDING 예약 저장 시작: memberId=$memberId, request=$request"
}
val reservation: ReservationEntity = reservationWriter.create(
date = request.date,
timeId = request.timeId,
themeId = request.themeId,
status = ReservationStatus.PENDING,
memberId = memberId,
requesterId = memberId
)
return reservation.toCreateResponseV2().also {
log.info {
"[ReservationWithPaymentServiceV2.createPendingReservation] " +
"PENDING 예약 저장 완료: reservationId=${reservation.id}, response=$it"
}
}
}
fun payReservation(
memberId: Long,
reservationId: Long,
request: ReservationPaymentRequest
): ReservationPaymentResponse {
log.info {
"[ReservationWithPaymentServiceV2.payReservation] " +
"예약 결제 시작: memberId=$memberId, reservationId=$reservationId, request=$request"
}
val paymentConfirmResponse: PaymentConfirmResponse = paymentRequester.requestConfirmPayment(
paymentKey = request.paymentKey,
orderId = request.orderId,
amount = request.amount
)
return transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
val payment: PaymentEntityV2 = paymentWriter.createPayment(reservationId, request, paymentConfirmResponse)
val reservation: ReservationEntity =
reservationWriter.modifyStatusFromPendingToConfirmed(reservationId, memberId)
ReservationPaymentResponse(reservationId, reservation.status, payment.id, payment.status)
.also { log.info { "[ReservationWithPaymentServiceV2.payReservation] 예약 결제 완료: response=${it}" } }
}
}
fun cancelReservation(
memberId: Long,
reservationId: Long,
request: ReservationCancelRequest
) {
log.info {
"[ReservationWithPaymentServiceV2.cancelReservation] " +
"예약 취소 시작: memberId=$memberId, reservationId=$reservationId, request=$request"
}
val reservation: ReservationEntity = reservationFinder.findById(reservationId)
val payment: PaymentEntityV2 = paymentFinder.findPaymentByReservationId(reservationId)
val paymentCancelResponse = paymentRequester.requestCancelPayment(
paymentKey = payment.paymentKey,
amount = payment.totalAmount,
cancelReason = request.cancelReason
)
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
paymentWriter.createCanceledPayment(memberId, payment, request.requestedAt, paymentCancelResponse)
reservationWriter.modifyStatusToCanceledByUser(reservation, memberId)
}.also {
log.info {
"[ReservationWithPaymentServiceV2.cancelReservation] " +
"예약 취소 완료: reservationId=$reservationId, memberId=$memberId, cancelReason=${request.cancelReason}"
}
}
}
}

View File

@ -1,104 +0,0 @@
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

@ -1,33 +0,0 @@
package roomescape.reservation.docs
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable
import roomescape.auth.web.support.LoginRequired
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.ReservationDetailRetrieveResponse
import roomescape.reservation.web.ReservationSummaryRetrieveListResponse
interface MyReservationAPI {
@LoginRequired
@Operation(summary = "내 예약 개요 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공"),
)
fun findAllMyReservations(
@MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>>
@LoginRequired
@Operation(summary = "예약 상세 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공"),
)
fun showReservationDetails(
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>>
}

View File

@ -1,61 +0,0 @@
package roomescape.reservation.docs
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.headers.Header
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
import roomescape.auth.web.support.LoginRequired
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.*
interface ReservationWithPaymentAPI {
@LoginRequired
@Operation(summary = "예약 추가", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "200",
description = "성공",
useReturnTypeSchema = true,
headers = [Header(
name = HttpHeaders.LOCATION,
description = "생성된 예약 정보 URL",
schema = Schema(example = "/reservations/1")
)]
)
)
fun createPendingReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateRequest
): ResponseEntity<CommonApiResponse<ReservationCreateResponseV2>>
@LoginRequired
@Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "204", description = "성공"),
)
fun cancelReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long,
@Valid @RequestBody cancelRequest: ReservationCancelRequest
): ResponseEntity<CommonApiResponse<Unit>>
@LoginRequired
@Operation(summary = "예약 결제", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공"),
)
fun createPaymentAndConfirmReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long,
@Valid @RequestBody request: ReservationPaymentRequest
): ResponseEntity<CommonApiResponse<ReservationPaymentResponse>>
}

View File

@ -1,113 +0,0 @@
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 findAllByMemberIdV2(memberId: Long): List<ReservationEntity> {
log.debug { "[ReservationFinder.findAllByMember] 시작: memberId=${memberId}" }
return reservationRepository.findAllByMember_Id(memberId)
.filter { it.status == ReservationStatus.CONFIRMED || it.status == ReservationStatus.CANCELED_BY_USER }
.sortedByDescending { it.date }
.also { log.debug { "[ReservationFinder.findAllByMember] ${it.size}개 예약 조회 완료: memberId=${memberId}" } }
}
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}" } }
}
fun findPendingReservation(reservationId: Long, memberId: Long): ReservationEntity {
log.debug { "[ReservationFinder.findPendingReservationIfExists] 시작: reservationId=$reservationId, memberId=$memberId" }
return findById(reservationId).also {
reservationValidator.validateIsReservedByMemberAndPending(it, memberId)
}.also {
log.debug { "[ReservationFinder.findPendingReservationIfExists] 완료: reservationId=${it.id}, status=${it.status}" }
}
}
}

View File

@ -1,178 +0,0 @@
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.reservation.infrastructure.persistence.ReservationStatus
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" }
}
fun validateIsReservedByMemberAndPending(reservation: ReservationEntity, requesterId: Long) {
if (reservation.member.id != requesterId) {
log.error { "[ReservationValidator.validateIsReservedByMemberAndPending] 예약자 본인이 아님: reservationId=${reservation.id}, reservation.memberId=${reservation.member.id} requesterId=$requesterId" }
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
}
if (reservation.status != ReservationStatus.PENDING) {
log.warn { "[ReservationValidator.validateIsReservedByMemberAndPending] 예약 상태가 대기 중이 아님: reservationId=${reservation.id}, status=${reservation.status}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_PENDING)
}
}
fun validateIsPending(reservation: ReservationEntity) {
log.debug { "[ReservationValidator.validateIsPending] 시작: reservationId=${reservation.id}, status=${reservation.status}" }
if (reservation.status != ReservationStatus.PENDING) {
log.warn { "[ReservationValidator.validateIsPending] 예약 상태가 결제 대기 중이 아님: reservationId=${reservation.id}, status=${reservation.status}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_PENDING)
}
log.debug { "[ReservationValidator.validateIsPending] 완료: reservationId=${reservation.id}, status=${reservation.status}" }
}
fun validateModifyAuthority(reservation: ReservationEntity, memberId: Long) {
log.debug { "[ReservationValidator.validateModifyAuthority] 시작: reservationId=${reservation.id}, memberId=$memberId" }
if (reservation.member.id != memberId) {
log.error { "[ReservationValidator.validateModifyAuthority] 예약자 본인이 아님: reservationId=${reservation.id}, reservation.memberId=${reservation.member.id} memberId=$memberId" }
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
}
log.debug { "[ReservationValidator.validateModifyAuthority] 완료: reservationId=${reservation.id}, memberId=$memberId" }
}
}

View File

@ -1,131 +0,0 @@
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.data.repository.findByIdOrNull
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}" }
}
fun modifyStatusToCanceledByUser(reservation: ReservationEntity, requesterId: Long) {
log.debug { "[ReservationWriter.cancel] 예약 취소 시작: reservationId=${reservation.id}, requesterId=$requesterId" }
memberFinder.findById(requesterId)
.also { reservationValidator.validateDeleteAuthority(reservation, requester = it) }
reservation.cancelByUser().also {
log.debug { "[ReservationWriter.cancel] 예약 취소 완료: reservationId=${reservation.id}" }
}
}
fun modifyStatusFromPendingToConfirmed(reservationId: Long, memberId: Long): ReservationEntity {
log.debug { "[ReservationWriter.confirmPendingReservation] 시작: reservationId=$reservationId, memberId=$memberId" }
return reservationRepository.findByIdOrNull(reservationId)?.also {
reservationValidator.validateIsPending(it)
reservationValidator.validateModifyAuthority(it, memberId)
it.confirm()
log.debug { "[ReservationWriter.confirmPendingReservation] 완료: reservationId=${it.id}, status=${it.status}" }
} ?: run {
log.warn { "[ReservationWriter.confirmPendingReservation] 예약을 찾을 수 없음: reservationId=$reservationId" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
}
}

View File

@ -1,80 +0,0 @@
package roomescape.reservation.infrastructure.persistence
import org.springframework.data.jpa.domain.Specification
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
class ReservationSearchSpecification(
private var spec: Specification<ReservationEntity> = Specification { _, _, _ -> null }
) {
fun sameThemeId(themeId: Long?): ReservationSearchSpecification = andIfNotNull(themeId?.let {
Specification { root, _, cb ->
cb.equal(root.get<ThemeEntity>("theme").get<Long>("id"), themeId)
}
})
fun sameMemberId(memberId: Long?): ReservationSearchSpecification = andIfNotNull(memberId?.let {
Specification { root, _, cb ->
cb.equal(root.get<MemberEntity>("member").get<Long>("id"), memberId)
}
})
fun sameTimeId(timeId: Long?): ReservationSearchSpecification = andIfNotNull(timeId?.let {
Specification { root, _, cb ->
cb.equal(root.get<TimeEntity>("time").get<Long>("id"), timeId)
}
})
fun sameDate(date: LocalDate?): ReservationSearchSpecification = andIfNotNull(date?.let {
Specification { root, _, cb ->
cb.equal(root.get<LocalDate>("date"), date)
}
})
fun status(vararg statuses: ReservationStatus) = andIfNotNull { root, _, cb ->
root.get<ReservationStatus>("status").`in`(statuses.toList())
}
fun confirmed(): ReservationSearchSpecification = andIfNotNull { root, _, cb ->
cb.or(
cb.equal(
root.get<ReservationStatus>("status"),
ReservationStatus.CONFIRMED
),
cb.equal(
root.get<ReservationStatus>("status"),
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
)
)
}
fun waiting(): ReservationSearchSpecification = andIfNotNull { root, _, cb ->
cb.equal(
root.get<ReservationStatus>("status"),
ReservationStatus.WAITING
)
}
fun dateStartFrom(dateFrom: LocalDate?): ReservationSearchSpecification = andIfNotNull(dateFrom?.let {
Specification { root, _, cb ->
cb.greaterThanOrEqualTo(root.get("date"), dateFrom)
}
})
fun dateEndAt(dateTo: LocalDate?): ReservationSearchSpecification = andIfNotNull(dateTo?.let {
Specification { root, _, cb ->
cb.lessThanOrEqualTo(root.get("date"), dateTo)
}
})
fun build(): Specification<ReservationEntity> {
return this.spec
}
private fun andIfNotNull(condition: Specification<ReservationEntity>?): ReservationSearchSpecification {
condition?.let { this.spec = this.spec.and(condition) }
return this
}
}

View File

@ -1,35 +0,0 @@
package roomescape.reservation.web
import io.swagger.v3.oas.annotations.Parameter
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.business.MyReservationFindService
import roomescape.reservation.docs.MyReservationAPI
@RestController
class MyReservationController(
private val reservationFindService: MyReservationFindService
) : MyReservationAPI {
@GetMapping("/v2/reservations")
override fun findAllMyReservations(
@MemberId @Parameter(hidden=true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>> {
val response = reservationFindService.findReservationsByMemberId(memberId)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/v2/reservations/{id}/details")
override fun showReservationDetails(
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>> {
val response = reservationFindService.showReservationDetails(reservationId)
return ResponseEntity.ok(CommonApiResponse(response))
}
}

View File

@ -1,196 +0,0 @@
package roomescape.reservation.web
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
import roomescape.payment.infrastructure.persistence.v2.PaymentBankTransferDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentCardDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEasypayPrepaidDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.PaymentDetailResponse.BankTransferDetailResponse
import roomescape.reservation.web.PaymentDetailResponse.CardDetailResponse
import roomescape.reservation.web.PaymentDetailResponse.EasyPayPrepaidDetailResponse
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
import kotlin.Int
data class ReservationSummaryRetrieveResponse(
val id: Long,
val themeName: String,
val date: LocalDate,
val startAt: LocalTime,
val status: ReservationStatus
)
fun ReservationEntity.toReservationSummaryRetrieveResponse(): ReservationSummaryRetrieveResponse {
return ReservationSummaryRetrieveResponse(
id = this.id!!,
themeName = this.theme.name,
date = this.date,
startAt = this.time.startAt,
status = this.status
)
}
data class ReservationSummaryRetrieveListResponse(
val reservations: List<ReservationSummaryRetrieveResponse>
)
fun List<ReservationEntity>.toSummaryListResponse(): ReservationSummaryRetrieveListResponse {
return ReservationSummaryRetrieveListResponse(
reservations = this.map { it.toReservationSummaryRetrieveResponse() }
)
}
data class ReservationDetailRetrieveResponse(
val id: Long,
val user: UserDetailRetrieveResponse,
val themeName: String,
val date: LocalDate,
val startAt: LocalTime,
val applicationDateTime: LocalDateTime,
val payment: PaymentRetrieveResponse,
val cancellation: PaymentCancelDetailResponse? = null
)
data class UserDetailRetrieveResponse(
val id: Long,
val name: String,
val email: String
)
fun MemberEntity.toUserDetailRetrieveResponse(): UserDetailRetrieveResponse {
return UserDetailRetrieveResponse(
id = this.id!!,
name = this.name,
email = this.email
)
}
fun ReservationEntity.toReservationDetailRetrieveResponse(
payment: PaymentRetrieveResponse,
cancellation: PaymentCancelDetailResponse? = null
): ReservationDetailRetrieveResponse {
return ReservationDetailRetrieveResponse(
id = this.id!!,
user = this.member.toUserDetailRetrieveResponse(),
themeName = this.theme.name,
date = this.date,
startAt = this.time.startAt,
applicationDateTime = this.createdAt!!,
payment = payment,
cancellation = cancellation,
)
}
data class PaymentRetrieveResponse(
val orderId: String,
val totalAmount: Int,
val method: String,
val status: PaymentStatus,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
val detail: PaymentDetailResponse,
)
fun PaymentEntityV2.toRetrieveResponse(detail: PaymentDetailResponse): PaymentRetrieveResponse {
return PaymentRetrieveResponse(
orderId = this.orderId,
totalAmount = this.totalAmount,
method = this.method.koreanName,
status = this.status,
requestedAt = this.requestedAt,
approvedAt = this.approvedAt,
detail = detail
)
}
sealed class PaymentDetailResponse {
data class CardDetailResponse(
val type: String = "CARD",
val issuerCode: String,
val cardType: String,
val ownerType: String,
val cardNumber: String,
val amount: Int,
val approvalNumber: String,
val installmentPlanMonths: Int,
val easypayProviderName: String?,
val easypayDiscountAmount: Int?,
) : PaymentDetailResponse()
data class BankTransferDetailResponse(
val type: String = "BANK_TRANSFER",
val bankName: String,
) : PaymentDetailResponse()
data class EasyPayPrepaidDetailResponse(
val type: String = "EASYPAY_PREPAID",
val providerName: String,
val amount: Int,
val discountAmount: Int,
) : PaymentDetailResponse()
}
fun PaymentDetailEntity.toPaymentDetailResponse(): PaymentDetailResponse {
return when (this) {
is PaymentCardDetailEntity -> this.toCardDetailResponse()
is PaymentBankTransferDetailEntity -> this.toBankTransferDetailResponse()
is PaymentEasypayPrepaidDetailEntity -> this.toEasyPayPrepaidDetailResponse()
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
}
}
fun PaymentCardDetailEntity.toCardDetailResponse(): CardDetailResponse {
return CardDetailResponse(
issuerCode = this.issuerCode.koreanName,
cardType = this.cardType.koreanName,
ownerType = this.ownerType.koreanName,
cardNumber = this.cardNumber,
amount = this.amount,
approvalNumber = this.approvalNumber,
installmentPlanMonths = this.installmentPlanMonths,
easypayProviderName = this.easypayProviderCode?.koreanName,
easypayDiscountAmount = this.easypayDiscountAmount
)
}
fun PaymentBankTransferDetailEntity.toBankTransferDetailResponse(): BankTransferDetailResponse {
return BankTransferDetailResponse(
bankName = this.bankCode.koreanName
)
}
fun PaymentEasypayPrepaidDetailEntity.toEasyPayPrepaidDetailResponse(): EasyPayPrepaidDetailResponse {
return EasyPayPrepaidDetailResponse(
providerName = this.easypayProviderCode.koreanName,
amount = this.amount,
discountAmount = this.discountAmount
)
}
data class PaymentCancelDetailResponse(
val cancellationRequestedAt: LocalDateTime,
val cancellationApprovedAt: OffsetDateTime?,
val cancelReason: String,
val canceledBy: Long,
)
fun CanceledPaymentEntityV2.toCancelDetailResponse(): PaymentCancelDetailResponse {
return PaymentCancelDetailResponse(
cancellationRequestedAt = this.requestedAt,
cancellationApprovedAt = this.canceledAt,
cancelReason = this.cancelReason,
canceledBy = this.canceledBy
)
}

View File

@ -1,40 +0,0 @@
package roomescape.reservation.web
import io.swagger.v3.oas.annotations.media.Schema
import roomescape.payment.infrastructure.client.PaymentApproveRequest
import java.time.LocalDate
data class AdminReservationCreateRequest(
val date: LocalDate,
val timeId: Long,
val themeId: Long,
val memberId: Long
)
data class ReservationCreateWithPaymentRequest(
val date: LocalDate,
val timeId: Long,
val themeId: Long,
@Schema(description = "결제 위젯을 통해 받은 결제 키")
val paymentKey: String,
@Schema(description = "결제 위젯을 통해 받은 주문번호.")
val orderId: String,
@Schema(description = "결제 위젯을 통해 받은 결제 금액")
val amount: Long,
@Schema(description = "결제 타입", example = "NORMAL")
val paymentType: String
)
fun ReservationCreateWithPaymentRequest.toPaymentApproveRequest(): PaymentApproveRequest = PaymentApproveRequest(
paymentKey, orderId, amount, paymentType
)
data class WaitingCreateRequest(
val date: LocalDate,
val timeId: Long,
val themeId: Long
)

View File

@ -1,93 +0,0 @@
package roomescape.reservation.web
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import roomescape.member.web.MemberRetrieveResponse
import roomescape.member.web.toRetrieveResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.web.ThemeRetrieveResponse
import roomescape.theme.web.toRetrieveResponse
import roomescape.time.web.TimeCreateResponse
import roomescape.time.web.toCreateResponse
import java.time.LocalDate
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(
val id: Long,
val themeName: String,
val date: LocalDate,
val time: LocalTime,
val status: ReservationStatus,
@Schema(description = "대기 순번. 확정된 예약은 0의 값을 가집니다.")
val rank: Long,
@Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.")
val paymentKey: String?,
@Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.")
val amount: Long?
)
data class MyReservationRetrieveListResponse(
@Schema(description = "현재 로그인한 회원의 예약 및 대기 목록")
val reservations: List<MyReservationRetrieveResponse>
)
fun List<MyReservationRetrieveResponse>.toRetrieveListResponse() = MyReservationRetrieveListResponse(this)
data class ReservationRetrieveResponse(
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.toRetrieveResponse(): ReservationRetrieveResponse = ReservationRetrieveResponse(
id = this.id!!,
date = this.date,
member = this.member.toRetrieveResponse(),
time = this.time.toCreateResponse(),
theme = this.theme.toRetrieveResponse(),
status = this.status
)
data class ReservationRetrieveListResponse(
val reservations: List<ReservationRetrieveResponse>
)
fun List<ReservationEntity>.toRetrieveListResponse()= ReservationRetrieveListResponse(
this.map { it.toRetrieveResponse() }
)

View File

@ -1,60 +0,0 @@
package roomescape.reservation.web
import io.swagger.v3.oas.annotations.Parameter
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.business.ReservationWithPaymentService
import roomescape.reservation.business.ReservationWithPaymentServiceV2
import roomescape.reservation.docs.ReservationWithPaymentAPI
@RestController
class ReservationWithPaymentController(
private val reservationWithPaymentService: ReservationWithPaymentServiceV2
) : ReservationWithPaymentAPI {
@PostMapping("/v2/reservations")
override fun createPendingReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateRequest
): ResponseEntity<CommonApiResponse<ReservationCreateResponseV2>> {
val response = reservationWithPaymentService.createPendingReservation(
memberId = memberId,
request = reservationCreateWithPaymentRequest
)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/v2/reservations/{id}/pay")
override fun createPaymentAndConfirmReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long,
@Valid @RequestBody request: ReservationPaymentRequest,
): ResponseEntity<CommonApiResponse<ReservationPaymentResponse>> {
val response = reservationWithPaymentService.payReservation(
memberId = memberId,
reservationId = reservationId,
request = request
)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/v2/reservations/{id}/cancel")
override fun cancelReservation(
@MemberId @Parameter(hidden = true) memberId: Long,
@PathVariable("id") reservationId: Long,
@Valid @RequestBody cancelRequest: ReservationCancelRequest
): ResponseEntity<CommonApiResponse<Unit>> {
reservationWithPaymentService.cancelReservation(memberId, reservationId, cancelRequest)
return ResponseEntity.noContent().build()
}
}

View File

@ -1,57 +0,0 @@
package roomescape.reservation.web
import roomescape.payment.infrastructure.client.v2.PaymentConfirmRequest
import roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.common.PaymentType
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
data class ReservationCreateRequest(
val date: LocalDate,
val timeId: Long,
val themeId: Long,
)
data class ReservationCreateResponseV2(
val reservationId: Long,
val memberEmail: String,
val date: LocalDate,
val startAt: LocalTime,
val themeName: String
)
fun ReservationEntity.toCreateResponseV2() = ReservationCreateResponseV2(
reservationId = this.id!!,
memberEmail = this.member.email,
date = this.date,
startAt = this.time.startAt,
themeName = this.theme.name
)
data class ReservationPaymentRequest(
val paymentKey: String,
val orderId: String,
val amount: Int,
val paymentType: PaymentType
)
fun ReservationPaymentRequest.toPaymentConfirmRequest() = PaymentConfirmRequest(
paymentKey = this.paymentKey,
amount = this.amount,
orderId = this.orderId,
)
data class ReservationPaymentResponse(
val reservationId: Long,
val reservationStatus: ReservationStatus,
val paymentId: Long,
val paymentStatus: PaymentStatus,
)
data class ReservationCancelRequest(
val cancelReason: String,
val requestedAt: LocalDateTime = LocalDateTime.now()
)

View File

@ -1,32 +0,0 @@
package roomescape.reservation_v2.infrastructure.persistence
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import roomescape.common.entity.AuditingBaseEntity
@Entity
@Table(name = "reservation")
class ReservationEntityV2(
id: Long,
val memberId: Long,
val scheduleId: Long,
val reserverName: String,
val reserverContact: String,
val participantCount: Short,
val requirement: String,
@Enumerated(value = EnumType.STRING)
var status: ReservationStatusV2,
): AuditingBaseEntity(id) {
fun confirm() { this.status = ReservationStatusV2.CONFIRMED }
fun fail() { this.status = ReservationStatusV2.FAILED }
fun cancel() { this.status = ReservationStatusV2.CANCELED }
}
enum class ReservationStatusV2 {
PENDING, CONFIRMED, CANCELED, FAILED, EXPIRED
}

View File

@ -1,8 +0,0 @@
package roomescape.reservation_v2.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface ReservationRepositoryV2 : JpaRepository<ReservationEntityV2, Long> {
fun findAllByMemberId(memberId: Long): List<ReservationEntityV2>
}

View File

@ -1,122 +0,0 @@
package roomescape.reservation.business
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import roomescape.payment.business.PaymentService
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.toCreateResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.ReservationCreateResponse
import roomescape.reservation.web.ReservationCreateWithPaymentRequest
import roomescape.util.*
class ReservationWithPaymentServiceTest : FunSpec({
val reservationService: ReservationWriteService = mockk()
val paymentService: PaymentService = mockk()
val reservationWithPaymentService = ReservationWithPaymentService(
reservationWriteService = reservationService,
paymentService = paymentService
)
val reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest = ReservationFixture.createRequest()
val paymentApproveResponse = PaymentFixture.createApproveResponse()
val memberId = 1L
val reservationEntity: ReservationEntity = ReservationFixture.create(
id = 1L,
date = reservationCreateWithPaymentRequest.date,
time = TimeFixture.create(id = reservationCreateWithPaymentRequest.timeId),
theme = ThemeFixture.create(id = reservationCreateWithPaymentRequest.themeId),
member = MemberFixture.create(id = memberId),
status = ReservationStatus.CONFIRMED
)
val paymentEntity: PaymentEntity = PaymentFixture.create(
id = 1L,
orderId = reservationCreateWithPaymentRequest.orderId,
paymentKey = reservationCreateWithPaymentRequest.paymentKey,
totalAmount = reservationCreateWithPaymentRequest.amount,
reservation = reservationEntity,
)
context("addReservationWithPayment") {
test("예약 및 결제 정보를 저장한다.") {
every {
reservationService.createReservationWithPayment(reservationCreateWithPaymentRequest, memberId)
} returns reservationEntity
every {
paymentService.createPayment(paymentApproveResponse, reservationEntity)
} returns paymentEntity.toCreateResponse()
val result: ReservationCreateResponse = reservationWithPaymentService.createReservationAndPayment(
request = reservationCreateWithPaymentRequest,
approvedPaymentInfo = paymentApproveResponse,
memberId = memberId
)
assertSoftly(result) {
this.id shouldBe reservationEntity.id
this.date shouldBe reservationEntity.date
this.member.id shouldBe reservationEntity.member.id
this.time.id shouldBe reservationEntity.time.id
this.theme.id shouldBe reservationEntity.theme.id
this.status shouldBe ReservationStatus.CONFIRMED
}
}
context("removeReservationWithPayment") {
test("예약 및 결제 정보를 삭제하고, 결제 취소 정보를 저장한다.") {
val paymentCancelRequest: PaymentCancelRequest = PaymentFixture.createCancelRequest().copy(
paymentKey = paymentEntity.paymentKey,
amount = paymentEntity.totalAmount,
cancelReason = "고객 요청"
)
every {
paymentService.createCanceledPayment(reservationEntity.id!!)
} returns paymentCancelRequest
every {
reservationService.deleteReservation(reservationEntity.id!!, reservationEntity.member.id!!)
} just Runs
val result: PaymentCancelRequest = reservationWithPaymentService.deleteReservationAndPayment(
reservationId = reservationEntity.id!!,
memberId = reservationEntity.member.id!!
)
result shouldBe paymentCancelRequest
}
}
context("isNotPaidReservation") {
test("결제된 예약이면 true를 반환한다.") {
every {
paymentService.existsByReservationId(reservationEntity.id!!)
} returns false
val result: Boolean = reservationWithPaymentService.isNotPaidReservation(reservationEntity.id!!)
result shouldBe true
}
test("결제되지 않은 예약이면 false를 반환한다.") {
every {
paymentService.existsByReservationId(reservationEntity.id!!)
} returns true
val result: Boolean = reservationWithPaymentService.isNotPaidReservation(reservationEntity.id!!)
result shouldBe false
}
}
}
})

View File

@ -1,205 +0,0 @@
package roomescape.reservation.infrastructure.persistence
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import jakarta.persistence.EntityManager
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.data.repository.findByIdOrNull
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.reservation.web.MyReservationRetrieveResponse
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.util.PaymentFixture
import roomescape.util.ReservationFixture
import roomescape.util.ThemeFixture
import roomescape.util.TimeFixture
@DataJpaTest(showSql = false)
class ReservationRepositoryTest(
val entityManager: EntityManager,
val reservationRepository: ReservationRepository,
) : FunSpec() {
init {
context("findByTime") {
val time = TimeFixture.create()
beforeTest {
listOf(
ReservationFixture.create(time = time),
ReservationFixture.create(
time = TimeFixture.create(
startAt = time.startAt.plusSeconds(1)
)
)
).forEach {
persistReservation(it)
}
entityManager.flush()
entityManager.clear()
}
test("입력된 시간과 일치하는 예약을 반환한다.") {
assertSoftly(reservationRepository.findAllByTime(time)) {
it shouldHaveSize 1
assertSoftly(it.first().time.startAt) { result ->
result.hour shouldBe time.startAt.hour
result.minute shouldBe time.startAt.minute
}
}
}
}
context("findByDateAndThemeId") {
val date = ReservationFixture.create().date
lateinit var theme1: ThemeEntity
lateinit var theme2: ThemeEntity
beforeTest {
theme1 = ThemeFixture.create(name = "theme1").also {
entityManager.persist(it)
}
theme2 = ThemeFixture.create(name = "theme2").also {
entityManager.persist(it)
}
listOf(
ReservationFixture.create(date = date, theme = theme1),
ReservationFixture.create(date = date.plusDays(1), theme = theme1),
ReservationFixture.create(date = date, theme = theme2),
).forEach {
entityManager.persist(it.time)
entityManager.persist(it.member)
entityManager.persist(it)
}
}
test("입력된 날짜와 테마 ID에 해당하는 예약을 반환한다.") {
assertSoftly(reservationRepository.findByDateAndThemeId(date, theme1.id!!)) {
it shouldHaveSize 1
it.first().theme shouldBe theme1
}
}
}
context("updateStatusByReservationId") {
lateinit var reservation: ReservationEntity
beforeTest {
reservation = ReservationFixture.create().also {
persistReservation(it)
}
entityManager.flush()
entityManager.clear()
}
test("예약 상태를 업데이트한다.") {
ReservationStatus.entries.forEach {
val reservationId = reservation.id!!
val updatedRows = reservationRepository.updateStatusByReservationId(reservationId, it)
updatedRows shouldBe 1
entityManager.flush()
entityManager.clear()
reservationRepository.findByIdOrNull(reservationId)?.also { updated ->
updated.status shouldBe it
}
}
}
}
context("isExistConfirmedReservation") {
lateinit var waiting: ReservationEntity
lateinit var confirmed: ReservationEntity
lateinit var confirmedPaymentRequired: ReservationEntity
beforeTest {
waiting = ReservationFixture.create(status = ReservationStatus.WAITING).also {
persistReservation(it)
}
confirmed = ReservationFixture.create(status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED).also {
persistReservation(it)
}
confirmedPaymentRequired =
ReservationFixture.create(status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED).also {
persistReservation(it)
}
entityManager.flush()
entityManager.clear()
}
test("예약이 없으면 false를 반환한다.") {
val maxId: Long = listOf(waiting, confirmed, confirmedPaymentRequired)
.maxOfOrNull { it.id ?: 0L } ?: 0L
reservationRepository.isExistConfirmedReservation(maxId + 1L) shouldBe false
}
test("예약이 대기중이면 false를 반환한다.") {
reservationRepository.isExistConfirmedReservation(waiting.id!!) shouldBe false
}
test("예약이 결제 완료 상태이면 true를 반환한다.") {
reservationRepository.isExistConfirmedReservation(confirmed.id!!) shouldBe true
}
test("예약이 결제 대기 상태이면 true를 반환한다.") {
reservationRepository.isExistConfirmedReservation(confirmedPaymentRequired.id!!) shouldBe true
}
}
context("findMyReservations") {
lateinit var reservation: ReservationEntity
beforeTest {
reservation = ReservationFixture.create()
persistReservation(reservation)
}
test("결제 정보를 포함한 회원의 예약 목록을 반환한다.") {
val payment: PaymentEntity = PaymentFixture.create(
reservation = reservation
).also {
entityManager.persist(it)
entityManager.flush()
entityManager.clear()
}
val result: List<MyReservationRetrieveResponse> =
reservationRepository.findAllByMemberId(reservation.member.id!!)
result shouldHaveSize 1
assertSoftly(result.first()) {
it.id shouldBe reservation.id
it.paymentKey shouldBe payment.paymentKey
it.amount shouldBe payment.totalAmount
}
}
test("결제 정보가 없다면 paymentKey와 amount는 null로 반환한다.") {
val result: List<MyReservationRetrieveResponse> =
reservationRepository.findAllByMemberId(reservation.member.id!!)
result shouldHaveSize 1
assertSoftly(result.first()) {
it.id shouldBe reservation.id
it.paymentKey shouldBe null
it.amount shouldBe null
}
}
}
}
fun persistReservation(reservation: ReservationEntity) {
entityManager.persist(reservation.time)
entityManager.persist(reservation.theme)
entityManager.persist(reservation.member)
entityManager.persist(reservation)
}
}

View File

@ -1,730 +0,0 @@
package roomescape.reservation.web
import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.restassured.module.kotlin.extensions.Given
import io.restassured.module.kotlin.extensions.Then
import io.restassured.module.kotlin.extensions.When
import jakarta.persistence.EntityManager
import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.transaction.support.TransactionTemplate
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.auth.web.support.MemberIdResolver
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.Role
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.client.TossPaymentClient
import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.util.*
import java.time.LocalDate
import java.time.LocalTime
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ReservationControllerTest(
@LocalServerPort val port: Int,
val entityManager: EntityManager,
val transactionTemplate: TransactionTemplate,
) : FunSpec({
extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_EACH_TEST))
}) {
@MockkBean
lateinit var paymentClient: TossPaymentClient
@SpykBean
lateinit var memberIdResolver: MemberIdResolver
@SpykBean
lateinit var memberService: MemberService
@MockkBean
lateinit var jwtHandler: JwtHandler
lateinit var testDataHelper: TestDataHelper
fun login(member: MemberEntity) {
every { jwtHandler.getMemberIdFromToken(any()) } returns member.id!!
every { memberService.findById(member.id!!) } returns member
every { memberIdResolver.resolveArgument(any(), any(), any(), any()) } returns member.id!!
}
init {
beforeSpec {
testDataHelper = TestDataHelper(entityManager, transactionTemplate)
}
context("POST /reservations") {
beforeTest {
val member = testDataHelper.createMember(role = Role.MEMBER)
login(member)
}
test("정상 응답") {
val reservationRequest = testDataHelper.createReservationRequest()
val paymentApproveResponse = PaymentFixture.createApproveResponse().copy(
paymentKey = reservationRequest.paymentKey,
orderId = reservationRequest.orderId,
totalAmount = reservationRequest.amount,
)
every { paymentClient.confirm(any()) } returns paymentApproveResponse
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(reservationRequest)
}.When {
post("/reservations")
}.Then {
statusCode(201)
body("data.date", equalTo(reservationRequest.date.toString()))
body("data.status", equalTo(ReservationStatus.CONFIRMED.name))
}
}
test("결제 과정에서 발생하는 에러는 그대로 응답") {
val reservationRequest = testDataHelper.createReservationRequest()
val paymentException = PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
every { paymentClient.confirm(any()) } throws paymentException
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(reservationRequest)
}.When {
post("/reservations")
}.Then {
statusCode(paymentException.errorCode.httpStatus.value())
body("code", equalTo(paymentException.errorCode.errorCode))
}
}
test("결제 완료 후 예약 / 결제 정보 저장 과정에서 에러 발생시 결제 취소 후 에러 응답을 받는다.") {
val reservationRequest = testDataHelper.createReservationRequest()
val paymentApproveResponse = PaymentFixture.createApproveResponse().copy(
paymentKey = reservationRequest.paymentKey,
orderId = reservationRequest.orderId,
totalAmount = reservationRequest.amount,
)
every { paymentClient.confirm(any()) } returns paymentApproveResponse
// 예약 저장 과정에서 테마가 없는 예외
val invalidRequest = reservationRequest.copy(themeId = reservationRequest.themeId + 1)
val expectedException = ThemeErrorCode.THEME_NOT_FOUND
every { paymentClient.cancel(any()) } returns PaymentFixture.createCancelResponse()
val canceledPaymentSizeBeforeApiCall: Long = entityManager.createQuery(
"SELECT COUNT(c) FROM CanceledPaymentEntity c",
Long::class.java
).singleResult
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(invalidRequest)
}.When {
post("/reservations")
}.Then {
statusCode(expectedException.httpStatus.value())
body("code", equalTo(expectedException.errorCode))
}
val canceledPaymentSizeAfterApiCall: Long = entityManager.createQuery(
"SELECT COUNT(c) FROM CanceledPaymentEntity c",
Long::class.java
).singleResult
canceledPaymentSizeAfterApiCall shouldBe canceledPaymentSizeBeforeApiCall + 1L
}
}
context("GET /reservations") {
lateinit var reservations: Map<MemberEntity, List<ReservationEntity>>
beforeTest {
reservations = testDataHelper.createDummyReservations()
}
test("관리자이면 정상 응답") {
login(testDataHelper.createMember(role = Role.ADMIN))
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations")
}.Then {
statusCode(200)
body("data.reservations.size()", equalTo(reservations.values.sumOf { it.size }))
}
}
}
context("GET /reservations-mine") {
lateinit var reservations: Map<MemberEntity, List<ReservationEntity>>
beforeTest {
reservations = testDataHelper.createDummyReservations()
}
test("로그인한 회원이 자신의 예약 목록을 조회한다.") {
val member = reservations.keys.first()
login(member)
val expectedReservations: Int = reservations[member]?.size ?: 0
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations-mine")
}.Then {
statusCode(200)
body("data.reservations.size()", equalTo(expectedReservations))
}
}
}
context("GET /reservations/search") {
lateinit var reservations: Map<MemberEntity, List<ReservationEntity>>
beforeTest {
reservations = testDataHelper.createDummyReservations()
}
test("관리자만 검색할 수 있다.") {
login(reservations.keys.first())
val expectedError = AuthErrorCode.ACCESS_DENIED
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations/search")
}.Then {
statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
}
}
test("파라미터를 지정하지 않으면 전체 목록 응답") {
login(testDataHelper.createMember(role = Role.ADMIN))
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations/search")
}.Then {
statusCode(200)
body("data.reservations.size()", equalTo(reservations.values.sumOf { it.size }))
}
}
test("시작 날짜가 종료 날짜 이전이면 예외 응답") {
login(testDataHelper.createMember(role = Role.ADMIN))
val startDate = LocalDate.now().plusDays(1)
val endDate = LocalDate.now()
val expectedError = ReservationErrorCode.INVALID_SEARCH_DATE_RANGE
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
param("dateFrom", startDate.toString())
param("dateTo", endDate.toString())
}.When {
get("/reservations/search")
}.Then {
statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
}
}
test("동일한 회원의 모든 예약 응답") {
login(testDataHelper.createMember(role = Role.ADMIN))
val member = reservations.keys.first()
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
param("memberId", member.id)
}.When {
get("/reservations/search")
}.Then {
statusCode(200)
body("data.reservations.size()", equalTo(reservations[member]?.size ?: 0))
}
}
test("동일한 테마의 모든 예약 응답") {
login(testDataHelper.createMember(role = Role.ADMIN))
val themes = reservations.values.flatten().map { it.theme }
val requestThemeId: Long = themes.first().id!!
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
param("themeId", requestThemeId)
}.When {
get("/reservations/search")
}.Then {
statusCode(200)
body("data.reservations.size()", equalTo(themes.count { it.id == requestThemeId }))
}
}
test("시작 날짜와 종료 날짜 사이의 예약 응답") {
login(testDataHelper.createMember(role = Role.ADMIN))
val dateFrom: LocalDate = reservations.values.flatten().minOf { it.date }
val dateTo: LocalDate = reservations.values.flatten().maxOf { it.date }
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
param("dateFrom", dateFrom.toString())
param("dateTo", dateTo.toString())
}.When {
get("/reservations/search")
}.Then {
statusCode(200)
body("data.reservations.size()", equalTo(reservations.values.sumOf { it.size }))
}
}
}
context("DELETE /reservations/{id}") {
lateinit var reservations: Map<MemberEntity, List<ReservationEntity>>
beforeTest {
reservations = testDataHelper.createDummyReservations()
}
test("관리자만 예약을 삭제할 수 있다.") {
login(testDataHelper.createMember(role = Role.MEMBER))
val reservation = reservations.values.flatten().first()
val expectedError = AuthErrorCode.ACCESS_DENIED
Given {
port(port)
}.When {
delete("/reservations/${reservation.id}")
}.Then {
statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
}
}
test("결제되지 않은 예약은 바로 제거") {
login(testDataHelper.createMember(role = Role.ADMIN))
val reservationId = reservations.values.flatten().first().id!!
transactionTemplate.executeWithoutResult {
val reservation = entityManager.find(ReservationEntity::class.java, reservationId)
reservation.status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
}
Given {
port(port)
}.When {
delete("/reservations/$reservationId")
}.Then {
statusCode(HttpStatus.NO_CONTENT.value())
}
val deletedReservation = transactionTemplate.execute {
entityManager.find(ReservationEntity::class.java, reservationId)
}
deletedReservation shouldBe null
}
test("결제된 예약은 취소 후 제거") {
login(testDataHelper.createMember(role = Role.ADMIN))
val reservation = reservations.values.flatten().first { it.status == ReservationStatus.CONFIRMED }
testDataHelper.createPayment(reservation)
every { paymentClient.cancel(any()) } returns PaymentFixture.createCancelResponse()
val canceledPaymentSizeBeforeApiCall: Long = entityManager.createQuery(
"SELECT COUNT(c) FROM CanceledPaymentEntity c",
Long::class.java
).singleResult
Given {
port(port)
}.When {
delete("/reservations/${reservation.id}")
}.Then {
statusCode(HttpStatus.NO_CONTENT.value())
}
val canceledPaymentSizeAfterApiCall: Long = entityManager.createQuery(
"SELECT COUNT(c) FROM CanceledPaymentEntity c",
Long::class.java
).singleResult
canceledPaymentSizeAfterApiCall shouldBe canceledPaymentSizeBeforeApiCall + 1L
}
}
context("POST /reservations/admin") {
test("관리자가 예약을 추가하면 결제 대기 상태로 예약 생성") {
val admin = testDataHelper.createMember(role = Role.ADMIN)
login(admin)
val theme = testDataHelper.createTheme()
val time = testDataHelper.createTime()
val adminRequest = AdminReservationCreateRequest(
date = LocalDate.now().plusDays(1),
themeId = theme.id!!,
timeId = time.id!!,
memberId = admin.id!!,
)
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(adminRequest)
}.When {
post("/reservations/admin")
}.Then {
statusCode(201)
body("data.status", equalTo(ReservationStatus.CONFIRMED_PAYMENT_REQUIRED.name))
}
}
}
context("GET /reservations/waiting") {
lateinit var reservations: Map<MemberEntity, List<ReservationEntity>>
beforeTest {
reservations = testDataHelper.createDummyReservations(reservationCount = 5)
}
test("관리자가 아니면 조회할 수 없다.") {
login(testDataHelper.createMember(role = Role.MEMBER))
val expectedError = AuthErrorCode.ACCESS_DENIED
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations/waiting")
}.Then {
statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
}
}
test("대기 중인 예약 목록을 조회한다.") {
login(testDataHelper.createMember(role = Role.ADMIN))
val expected = reservations.values.flatten()
.count { it.status == ReservationStatus.WAITING }
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
}.When {
get("/reservations/waiting")
}.Then {
statusCode(200)
body("data.reservations.size()", equalTo(expected))
}
}
}
context("POST /reservations/waiting") {
test("회원이 대기 예약을 추가한다.") {
val member = testDataHelper.createMember(role = Role.MEMBER)
login(member)
val theme = testDataHelper.createTheme()
val time = testDataHelper.createTime()
val waitingCreateRequest = WaitingCreateRequest(
date = LocalDate.now().plusDays(1),
themeId = theme.id!!,
timeId = time.id!!
)
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(waitingCreateRequest)
}.When {
post("/reservations/waiting")
}.Then {
statusCode(201)
body("data.member.id", equalTo(member.id!!.toString()))
body("data.status", equalTo(ReservationStatus.WAITING.name))
}
}
test("이미 예약된 시간, 테마로 대기 예약 요청 시 예외 응답") {
val member = testDataHelper.createMember(role = Role.MEMBER)
login(member)
val theme = testDataHelper.createTheme()
val time = testDataHelper.createTime()
val date = LocalDate.now().plusDays(1)
testDataHelper.createReservation(
date = date,
theme = theme,
time = time,
member = member,
status = ReservationStatus.CONFIRMED
)
val waitingCreateRequest = WaitingCreateRequest(
date = date,
themeId = theme.id!!,
timeId = time.id!!
)
val expectedError = ReservationErrorCode.ALREADY_RESERVE
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
body(waitingCreateRequest)
}.When {
post("/reservations/waiting")
}.Then {
statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
}
}
}
context("DELETE /reservations/waiting/{id}") {
test("대기 중인 예약을 취소한다.") {
val member = testDataHelper.createMember(role = Role.MEMBER)
login(member)
val waiting = testDataHelper.createReservation(
member = member,
status = ReservationStatus.WAITING
)
Given {
port(port)
}.When {
delete("/reservations/waiting/${waiting.id}")
}.Then {
statusCode(HttpStatus.NO_CONTENT.value())
}
val deleted = transactionTemplate.execute {
entityManager.find(ReservationEntity::class.java, waiting.id)
}
deleted shouldBe null
}
test("이미 확정된 예약을 삭제하면 예외 응답") {
val member = testDataHelper.createMember(role = Role.MEMBER)
login(member)
val reservation = testDataHelper.createReservation(
member = member,
status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
)
val expectedError = ReservationErrorCode.ALREADY_CONFIRMED
Given {
port(port)
}.When {
delete("/reservations/waiting/${reservation.id}")
}.Then {
statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
}
}
}
context("POST /reservations/waiting/{id}/confirm") {
test("관리자만 승인할 수 있다.") {
login(testDataHelper.createMember(role = Role.MEMBER))
val expectedError = AuthErrorCode.ACCESS_DENIED
Given {
port(port)
}.When {
post("/reservations/waiting/1/confirm")
}.Then {
statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
}
}
test("대기 예약을 승인하면 결제 대기 상태로 변경") {
login(testDataHelper.createMember(role = Role.ADMIN))
val reservation = testDataHelper.createReservation(
status = ReservationStatus.WAITING
)
Given {
port(port)
}.When {
post("/reservations/waiting/${reservation.id!!}/confirm")
}.Then {
statusCode(200)
}
val updatedReservation = transactionTemplate.execute {
entityManager.find(ReservationEntity::class.java, reservation.id)
}
updatedReservation?.status shouldBe ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
}
test("다른 확정된 예약을 승인하면 예외 응답") {
val admin = testDataHelper.createMember(role = Role.ADMIN)
login(admin)
val alreadyReserved = testDataHelper.createReservation(
member = admin,
status = ReservationStatus.CONFIRMED
)
val member = testDataHelper.createMember(role = Role.MEMBER)
val waiting = testDataHelper.createReservation(
date = alreadyReserved.date,
time = alreadyReserved.time,
theme = alreadyReserved.theme,
member = member,
status = ReservationStatus.WAITING
)
val expectedError = ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS
Given {
port(port)
}.When {
post("/reservations/waiting/${waiting.id!!}/confirm")
}.Then {
statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
}
}
}
context("POST /reservations/waiting/{id}/reject") {
test("관리자만 거절할 수 있다.") {
login(testDataHelper.createMember(role = Role.MEMBER))
val expectedError = AuthErrorCode.ACCESS_DENIED
Given {
port(port)
}.When {
post("/reservations/waiting/1/reject")
}.Then {
statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
}
}
test("거절된 예약은 삭제된다.") {
login(testDataHelper.createMember(role = Role.ADMIN))
val reservation = testDataHelper.createReservation(
status = ReservationStatus.WAITING
)
Given {
port(port)
}.When {
post("/reservations/waiting/${reservation.id!!}/reject")
}.Then {
statusCode(204)
}
val rejected = transactionTemplate.execute {
entityManager.find(ReservationEntity::class.java, reservation.id)
}
rejected shouldBe null
}
}
}
}
class TestDataHelper(
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate,
) {
private var memberSequence = 0L
private var themeSequence = 0L
private var timeSequence = 0L
fun createMember(
role: Role = Role.MEMBER,
account: String = "member${++memberSequence}@test.com",
): MemberEntity {
val member = MemberFixture.create(role = role, account = account)
return persist(member)
}
fun createTheme(name: String = "theme-${++themeSequence}"): ThemeEntity {
val theme = ThemeFixture.create(name = name)
return persist(theme)
}
fun createTime(startAt: LocalTime = LocalTime.of(10, 0).plusMinutes(++timeSequence * 10)): TimeEntity {
val time = TimeFixture.create(startAt = startAt)
return persist(time)
}
fun createReservation(
date: LocalDate = LocalDate.now().plusDays(1),
theme: ThemeEntity = createTheme(),
time: TimeEntity = createTime(),
member: MemberEntity = createMember(),
status: ReservationStatus = ReservationStatus.CONFIRMED,
): ReservationEntity {
val reservation = ReservationFixture.create(
date = date,
theme = theme,
time = time,
member = member,
status = status
)
return persist(reservation)
}
fun createPayment(reservation: ReservationEntity): PaymentEntity {
val payment = PaymentFixture.create(reservation = reservation)
return persist(payment)
}
fun createReservationRequest(
theme: ThemeEntity = createTheme(),
time: TimeEntity = createTime(),
): ReservationCreateWithPaymentRequest {
return ReservationFixture.createRequest(
themeId = theme.id!!,
timeId = time.id!!,
)
}
fun createDummyReservations(
memberCount: Int = 2,
reservationCount: Int = 10,
): Map<MemberEntity, List<ReservationEntity>> {
val members = (1..memberCount).map { createMember(role = Role.MEMBER) }
val reservations = (1..reservationCount).map { index ->
createReservation(
member = members[index % memberCount],
status = ReservationStatus.CONFIRMED
)
}
return reservations.groupBy { it.member }
}
private fun <T> persist(entity: T): T {
transactionTemplate.executeWithoutResult {
entityManager.persist(entity)
}
return entity
}
}