diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt index 315e1b6f..00ef7fa7 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt @@ -42,11 +42,18 @@ class ReservationService( ): PendingReservationCreateResponse { log.info { "[createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" } - validateCanCreate(request) + run { + val schedule = scheduleService.findSummaryWithLock(request.scheduleId) + val theme = themeService.findInfoById(schedule.themeId) - val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id) + reservationValidator.validateCanCreate(schedule, theme, request) + } - return PendingReservationCreateResponse(reservationRepository.save(reservation).id) + val reservation: ReservationEntity = request.toEntity(id = idGenerator.create(), userId = user.id).also { + reservationRepository.save(it) + } + + return PendingReservationCreateResponse(reservation.id) .also { log.info { "[createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } } } @@ -153,10 +160,4 @@ class ReservationService( } } - private fun validateCanCreate(request: PendingReservationCreateRequest) { - val schedule = scheduleService.findSummaryWithLock(request.scheduleId) - val theme = themeService.findInfoById(schedule.themeId) - - reservationValidator.validateCanCreate(schedule, theme, request) - } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt index 9eb230aa..fb410b13 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt @@ -1,5 +1,7 @@ package com.sangdol.roomescape.reservation.business +import com.sangdol.common.utils.KoreaDateTime +import com.sangdol.common.utils.toKoreaDateTime import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest @@ -9,6 +11,8 @@ import com.sangdol.roomescape.theme.web.ThemeInfoResponse import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Component +import java.time.Instant +import java.time.LocalDateTime private val log: KLogger = KotlinLogging.logger {} @@ -20,11 +24,31 @@ class ReservationValidator { theme: ThemeInfoResponse, request: PendingReservationCreateRequest ) { + validateSchedule(schedule) + validateReservationInfo(theme, request) + } + private fun validateSchedule(schedule: ScheduleSummaryResponse) { if (schedule.status != ScheduleStatus.HOLD) { - log.warn { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } + log.info { "[validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) } + val scheduleHoldExpiredAt = schedule.holdExpiredAt + val nowInstant = Instant.now() + if (scheduleHoldExpiredAt != null && scheduleHoldExpiredAt.isBefore(nowInstant)) { + log.info { "[validateCanCreate] 해당 일정의 HOLD 만료 시간 초과로 인한 실패: expiredAt=${scheduleHoldExpiredAt.toKoreaDateTime()}(KST), now=${nowInstant.toKoreaDateTime()}(KST)" } + throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) + } + + val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.time) + val nowDateTime = KoreaDateTime.now() + if (scheduleDateTime.isBefore(nowDateTime)) { + log.info { "[validateCanCreate] 과거 시간인 일정으로 인한 실패: scheduleDateTime=${scheduleDateTime}(KST), now=${nowDateTime}" } + throw ReservationException(ReservationErrorCode.PAST_SCHEDULE) + } + } + + private fun validateReservationInfo(theme: ThemeInfoResponse, request: PendingReservationCreateRequest) { if (theme.minParticipants > request.participantCount) { log.info { "[validateCanCreate] 최소 인원 미달로 인한 예약 실패: minParticipants=${theme.minParticipants}, participantCount=${request.participantCount}" } throw ReservationException(ReservationErrorCode.INVALID_PARTICIPANT_COUNT) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt index 82158113..e68c91ea 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt @@ -12,6 +12,7 @@ enum class ReservationErrorCode( NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."), INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."), EXPIRED_HELD_SCHEDULE(HttpStatus.CONFLICT, "R004", "예약 정보 입력 시간을 초과했어요. 일정 선택 후 다시 시도해주세요."), - INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요.") + INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요."), + PAST_SCHEDULE(HttpStatus.BAD_REQUEST, "R006", "지난 일정은 예약할 수 없어요.") ; } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt index 9d255626..79bf9a00 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt @@ -2,6 +2,8 @@ package com.sangdol.roomescape.reservation import com.sangdol.common.types.exception.CommonErrorCode import com.sangdol.common.types.web.HttpStatus +import com.sangdol.common.utils.KoreaDate +import com.sangdol.common.utils.KoreaTime import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.payment.infrastructure.common.BankCode import com.sangdol.roomescape.payment.infrastructure.common.CardIssuerCode @@ -19,7 +21,6 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleReposi import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.supports.* import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeEntity -import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import org.hamcrest.CoreMatchers.equalTo @@ -31,7 +32,6 @@ import java.time.LocalTime class ReservationApiTest( private val reservationRepository: ReservationRepository, private val canceledReservationRepository: CanceledReservationRepository, - private val themeRepository: ThemeRepository, private val scheduleRepository: ScheduleRepository, private val paymentDetailRepository: PaymentDetailRepository, ) : FunSpecSpringbootTest() { @@ -91,18 +91,46 @@ class ReservationApiTest( test("예약을 생성할 때 해당 일정이 ${ScheduleStatus.HOLD} 상태가 아니면 실패한다.") { val schedule: ScheduleEntity = dummyInitializer.createSchedule(status = ScheduleStatus.AVAILABLE) - runTest( + runExceptionTest( token = testAuthUtil.defaultUserLogin().second, - using = { - body(commonRequest.copy(scheduleId = schedule.id)) - }, - on = { - post(endpoint) - }, - expect = { - statusCode(HttpStatus.CONFLICT.value()) - body("code", equalTo(ReservationErrorCode.EXPIRED_HELD_SCHEDULE.errorCode)) - } + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = commonRequest.copy(scheduleId = schedule.id), + expectedErrorCode = ReservationErrorCode.EXPIRED_HELD_SCHEDULE + ) + } + + test("해당 일정의 hold_expired_at 시간이 지났다면 실패한다.") { + val schedule: ScheduleEntity = dummyInitializer.createSchedule( + status = ScheduleStatus.HOLD, + isHoldExpired = true + ) + + runExceptionTest( + token = testAuthUtil.defaultUserLogin().second, + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = commonRequest.copy(scheduleId = schedule.id), + expectedErrorCode = ReservationErrorCode.EXPIRED_HELD_SCHEDULE + ) + } + + test("현재 시간이 일정의 시작 시간 이후이면 실패한다.") { + val schedule: ScheduleEntity = dummyInitializer.createSchedule( + status = ScheduleStatus.HOLD, + isHoldExpired = false, + request = ScheduleFixture.createRequest.copy( + date = KoreaDate.today(), + time = KoreaTime.now() + ) + ) + + runExceptionTest( + token = testAuthUtil.defaultUserLogin().second, + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = commonRequest.copy(scheduleId = schedule.id), + expectedErrorCode = ReservationErrorCode.PAST_SCHEDULE ) } @@ -120,7 +148,7 @@ class ReservationApiTest( method = HttpMethod.POST, endpoint = endpoint, requestBody = commonRequest.copy( - schedule.id, + scheduleId = schedule.id, participantCount = ((theme.minParticipants - 1).toShort()) ), expectedErrorCode = ReservationErrorCode.INVALID_PARTICIPANT_COUNT diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt index 75fc7279..005f8a5b 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt @@ -3,24 +3,26 @@ package com.sangdol.roomescape.reservation import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.reservation.business.ReservationService import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler +import com.sangdol.roomescape.reservation.exception.ReservationErrorCode +import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.web.PendingReservationCreateResponse +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity import io.kotest.assertions.assertSoftly import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import org.springframework.data.repository.findByIdOrNull import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.support.TransactionTemplate -import java.time.Instant class ReservationConcurrencyTest( private val transactionManager: PlatformTransactionManager, @@ -31,53 +33,76 @@ class ReservationConcurrencyTest( ) : FunSpecSpringbootTest() { init { - test("Pending 예약 생성시, Schedule 상태 검증 이후부터 커밋 이전 사이에 시작된 schedule 처리 배치 작업은 반영되지 않는다.") { - val user = testAuthUtil.defaultUserLogin().first - val schedule = dummyInitializer.createSchedule().also { - it.status = ScheduleStatus.HOLD - it.holdExpiredAt = Instant.now().minusSeconds(1 * 60) - scheduleRepository.save(it) + context("Pending 예약 생성 도중 시작되는 schedule 재활성화 배치는 해당 로직의 완료 이후에 처리된다.") { + lateinit var user: UserEntity + + beforeTest { + user = testAuthUtil.defaultUserLogin().first } - lateinit var response: PendingReservationCreateResponse - withContext(Dispatchers.IO) { - val createPendingReservationJob = async { - response = TransactionTemplate(transactionManager).execute { - val response = reservationService.createPendingReservation( - user = CurrentUserContext(id = user.id, name = user.name), - request = PendingReservationCreateRequest( - scheduleId = schedule.id, - reserverName = user.name, - reserverContact = user.phone, - participantCount = 3, - requirement = "없어요!" - ) - ) + test("holdExpiredAt이 지난 일정인 경우, Pending 예약 생성 중 예외가 발생하고 이후 배치가 재활성화 처리한다.") { + val schedule = dummyInitializer.createSchedule( + status = ScheduleStatus.HOLD, + isHoldExpired = true + ) - Thread.sleep(200) - response - }!! - } + try { + runConcurrency(user, schedule) + } catch (e: ReservationException) { + e.errorCode shouldBe ReservationErrorCode.EXPIRED_HELD_SCHEDULE - val updateScheduleJob = async { - TransactionTemplate(transactionManager).execute { - incompletedReservationScheduler.processExpiredHoldSchedule() + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.AVAILABLE + this.holdExpiredAt shouldBe null } } - - listOf(createPendingReservationJob, updateScheduleJob).awaitAll() } + test("holdExpiredAt이 지나지 않은 일정인 경우, Pending 예약 생성이 정상적으로 종료되며 배치 작업이 적용되지 않는다.") { + val schedule = dummyInitializer.createSchedule( + status = ScheduleStatus.HOLD, + isHoldExpired = false + ) - assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { - this.status shouldBe ScheduleStatus.HOLD - this.holdExpiredAt.shouldNotBeNull() - } + val response = runConcurrency(user, schedule) - assertSoftly(reservationRepository.findByIdOrNull(response.id)) { - this.shouldNotBeNull() - this.status shouldBe ReservationStatus.PENDING + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.HOLD + this.holdExpiredAt.shouldNotBeNull() + } + + assertSoftly(reservationRepository.findByIdOrNull(response.id)) { + this.shouldNotBeNull() + this.status shouldBe ReservationStatus.PENDING + } } } } + + private suspend fun runConcurrency(user: UserEntity, schedule: ScheduleEntity): PendingReservationCreateResponse { + return withContext(Dispatchers.IO) { + val createPendingReservationJob = async { + TransactionTemplate(transactionManager).execute { + reservationService.createPendingReservation( + user = CurrentUserContext(id = user.id, name = user.name), + request = PendingReservationCreateRequest( + scheduleId = schedule.id, + reserverName = user.name, + reserverContact = user.phone, + participantCount = 3, + requirement = "없어요!" + ) + ) + }!! + } + + val updateScheduleJob = async { + TransactionTemplate(transactionManager).execute { + incompletedReservationScheduler.processExpiredHoldSchedule() + } + } + + createPendingReservationJob.await().also { updateScheduleJob.await() } + } + } }