generated from pricelees/issue-pr-template
[#66] 결제 & 예약 확정 로직 수정 #67
@ -1,46 +1,38 @@
|
|||||||
package com.sangdol.roomescape.order
|
package com.sangdol.roomescape.order
|
||||||
|
|
||||||
import com.ninjasquad.springmockk.SpykBean
|
import com.ninjasquad.springmockk.MockkBean
|
||||||
import com.sangdol.common.utils.KoreaDate
|
import com.sangdol.common.utils.KoreaDate
|
||||||
import com.sangdol.common.utils.KoreaTime
|
import com.sangdol.common.utils.KoreaTime
|
||||||
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
import com.sangdol.roomescape.auth.exception.AuthErrorCode
|
||||||
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
import com.sangdol.roomescape.order.exception.OrderErrorCode
|
||||||
import com.sangdol.roomescape.order.infrastructure.persistence.AttemptResult
|
|
||||||
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptEntity
|
|
||||||
import com.sangdol.roomescape.order.infrastructure.persistence.PaymentAttemptRepository
|
|
||||||
import com.sangdol.roomescape.order.infrastructure.persistence.PostOrderTaskRepository
|
|
||||||
import com.sangdol.roomescape.payment.business.PaymentService
|
|
||||||
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
import com.sangdol.roomescape.payment.business.domain.PaymentMethod
|
||||||
|
import com.sangdol.roomescape.payment.business.event.PaymentEvent
|
||||||
|
import com.sangdol.roomescape.payment.business.event.PaymentEventListener
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
import com.sangdol.roomescape.payment.dto.PaymentConfirmRequest
|
||||||
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
import com.sangdol.roomescape.payment.dto.PaymentGatewayResponse
|
||||||
|
import com.sangdol.roomescape.payment.exception.ExternalPaymentException
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
import com.sangdol.roomescape.payment.exception.PaymentErrorCode
|
||||||
import com.sangdol.roomescape.payment.exception.PaymentException
|
import com.sangdol.roomescape.payment.infrastructure.client.TosspayClient
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentDetailRepository
|
import com.sangdol.roomescape.reservation.business.event.ReservationConfirmEvent
|
||||||
import com.sangdol.roomescape.payment.infrastructure.persistence.PaymentRepository
|
import com.sangdol.roomescape.reservation.business.event.ReservationEventListener
|
||||||
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
|
import com.sangdol.roomescape.reservation.exception.ReservationErrorCode
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.supports.*
|
import com.sangdol.roomescape.supports.*
|
||||||
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
||||||
import io.kotest.assertions.assertSoftly
|
import io.kotest.assertions.assertSoftly
|
||||||
import io.kotest.matchers.booleans.shouldBeTrue
|
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.mockk.every
|
import io.mockk.*
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
|
||||||
class OrderApiTest(
|
class OrderApiTest(
|
||||||
@SpykBean private val paymentService: PaymentService,
|
@MockkBean(relaxed = true) private val paymentClient: TosspayClient,
|
||||||
private val paymentAttemptRepository: PaymentAttemptRepository,
|
@MockkBean(relaxed = true) private val reservationEventListener: ReservationEventListener,
|
||||||
|
@MockkBean(relaxed = true) private val paymentEventListener: PaymentEventListener,
|
||||||
private val reservationRepository: ReservationRepository,
|
private val reservationRepository: ReservationRepository,
|
||||||
private val postOrderTaskRepository: PostOrderTaskRepository,
|
|
||||||
private val scheduleRepository: ScheduleRepository,
|
|
||||||
private val paymentRepository: PaymentRepository,
|
|
||||||
private val paymentDetailRepository: PaymentDetailRepository
|
|
||||||
) : FunSpecSpringbootTest() {
|
) : FunSpecSpringbootTest() {
|
||||||
|
|
||||||
val paymentRequest: PaymentConfirmRequest = PaymentFixture.confirmRequest
|
val paymentRequest: PaymentConfirmRequest = PaymentFixture.confirmRequest
|
||||||
@ -82,39 +74,52 @@ class OrderApiTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("정상 응답") {
|
test("정상 응답") {
|
||||||
val reservation = dummyInitializer.createPendingReservation(user)
|
val reservationId = dummyInitializer.createPendingReservation(user).id
|
||||||
|
|
||||||
|
val reservationConfirmEventSlot = slot<ReservationConfirmEvent>()
|
||||||
|
val paymentEventSlot = slot<PaymentEvent>()
|
||||||
|
|
||||||
every {
|
every {
|
||||||
paymentService.requestConfirm(paymentRequest)
|
paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
|
||||||
} returns expectedPaymentResponse
|
} returns expectedPaymentResponse
|
||||||
|
|
||||||
|
every {
|
||||||
|
paymentEventListener.handlePaymentEvent(capture(paymentEventSlot))
|
||||||
|
} just runs
|
||||||
|
|
||||||
|
every {
|
||||||
|
reservationEventListener.handleReservationConfirmEvent(capture(reservationConfirmEventSlot))
|
||||||
|
} just runs
|
||||||
|
|
||||||
runTest(
|
runTest(
|
||||||
token = token,
|
token = token,
|
||||||
using = {
|
using = {
|
||||||
body(paymentRequest)
|
body(paymentRequest)
|
||||||
},
|
},
|
||||||
on = {
|
on = {
|
||||||
post("/orders/${reservation.id}/confirm")
|
post("/orders/${reservationId}/confirm")
|
||||||
},
|
},
|
||||||
expect = {
|
expect = {
|
||||||
statusCode(HttpStatus.OK.value())
|
statusCode(HttpStatus.OK.value())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) {
|
verify(exactly = 1) {
|
||||||
this.status shouldBe ScheduleStatus.RESERVED
|
paymentEventListener.handlePaymentEvent(any())
|
||||||
this.holdExpiredAt shouldBe null
|
}.also {
|
||||||
|
assertSoftly(paymentEventSlot.captured) {
|
||||||
|
this.paymentKey shouldBe expectedPaymentResponse.paymentKey
|
||||||
|
this.reservationId shouldBe reservationId
|
||||||
}
|
}
|
||||||
reservationRepository.findByIdOrNull(reservation.id)!!.status shouldBe ReservationStatus.CONFIRMED
|
|
||||||
|
|
||||||
assertSoftly(paymentRepository.findByReservationId(reservation.id)) {
|
|
||||||
this.shouldNotBeNull()
|
|
||||||
this.status shouldBe expectedPaymentResponse.status
|
|
||||||
|
|
||||||
paymentDetailRepository.findByPaymentId(this.id)!!.shouldNotBeNull()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue()
|
verify(exactly = 1) {
|
||||||
|
reservationEventListener.handleReservationConfirmEvent(any())
|
||||||
|
}.also {
|
||||||
|
assertSoftly(reservationConfirmEventSlot.captured) {
|
||||||
|
this.reservationId shouldBe reservationId
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context("검증 과정에서의 실패 응답") {
|
context("검증 과정에서의 실패 응답") {
|
||||||
@ -128,24 +133,6 @@ class OrderApiTest(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("이미 결제가 완료된 예약이면 실패한다.") {
|
|
||||||
val reservation = dummyInitializer.createPendingReservation(user)
|
|
||||||
|
|
||||||
paymentAttemptRepository.save(PaymentAttemptEntity(
|
|
||||||
id = IDGenerator.create(),
|
|
||||||
reservationId = reservation.id,
|
|
||||||
result = AttemptResult.SUCCESS
|
|
||||||
))
|
|
||||||
|
|
||||||
runExceptionTest(
|
|
||||||
token = token,
|
|
||||||
method = HttpMethod.POST,
|
|
||||||
endpoint = "/orders/${reservation.id}/confirm",
|
|
||||||
requestBody = paymentRequest,
|
|
||||||
expectedErrorCode = OrderErrorCode.NOT_CONFIRMABLE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("이미 확정된 예약이면 실패한다.") {
|
test("이미 확정된 예약이면 실패한다.") {
|
||||||
val reservation = dummyInitializer.createConfirmReservation(user)
|
val reservation = dummyInitializer.createConfirmReservation(user)
|
||||||
|
|
||||||
@ -223,68 +210,23 @@ class OrderApiTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
context("결제 과정에서의 실패 응답.") {
|
context("결제 과정에서의 실패 응답.") {
|
||||||
test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태로 바꾸고, 결제 시도 이력을 기록한다.") {
|
test("결제에 실패해도 검증을 통과하면 예약을 ${ReservationStatus.PAYMENT_IN_PROGRESS} 상태가 된다.") {
|
||||||
val reservation = dummyInitializer.createPendingReservation(user)
|
val reservationId = dummyInitializer.createPendingReservation(user).id
|
||||||
|
|
||||||
every {
|
every {
|
||||||
paymentService.requestConfirm(paymentRequest)
|
paymentClient.confirm(paymentRequest.paymentKey, paymentRequest.orderId, paymentRequest.amount)
|
||||||
} throws PaymentException(PaymentErrorCode.PAYMENT_CLIENT_ERROR)
|
} throws ExternalPaymentException(400, "INVALID_REQUEST", "잘못 요청함")
|
||||||
|
|
||||||
runExceptionTest(
|
runExceptionTest(
|
||||||
token = token,
|
token = token,
|
||||||
method = HttpMethod.POST,
|
method = HttpMethod.POST,
|
||||||
endpoint = "/orders/${reservation.id}/confirm",
|
endpoint = "/orders/${reservationId}/confirm",
|
||||||
requestBody = paymentRequest,
|
requestBody = paymentRequest,
|
||||||
expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR
|
expectedErrorCode = PaymentErrorCode.PAYMENT_CLIENT_ERROR
|
||||||
).also {
|
|
||||||
it.extract().path<Long>("trial") shouldBe 0
|
|
||||||
}
|
|
||||||
|
|
||||||
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
|
|
||||||
this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS
|
|
||||||
}
|
|
||||||
|
|
||||||
val paymentAttempt = paymentAttemptRepository.findAll().first { it.reservationId == reservation.id }
|
|
||||||
assertSoftly(paymentAttempt) {
|
|
||||||
it.shouldNotBeNull()
|
|
||||||
it.result shouldBe AttemptResult.FAILED
|
|
||||||
it.errorCode shouldBe PaymentErrorCode.PAYMENT_CLIENT_ERROR.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
context("결제 성공 이후 실패 응답.") {
|
|
||||||
test("결제 이력 저장 과정에서 예외가 발생하면 해당 작업을 저장하며, 사용자는 정상 응답을 받는다.") {
|
|
||||||
val reservation = dummyInitializer.createPendingReservation(user)
|
|
||||||
|
|
||||||
every {
|
|
||||||
paymentService.requestConfirm(paymentRequest)
|
|
||||||
} returns expectedPaymentResponse
|
|
||||||
|
|
||||||
every {
|
|
||||||
paymentService.savePayment(reservation.id, expectedPaymentResponse)
|
|
||||||
} throws RuntimeException("결제 저장 실패!")
|
|
||||||
|
|
||||||
runTest(
|
|
||||||
token = token,
|
|
||||||
using = {
|
|
||||||
body(paymentRequest)
|
|
||||||
},
|
|
||||||
on = {
|
|
||||||
post("/orders/${reservation.id}/confirm")
|
|
||||||
},
|
|
||||||
expect = {
|
|
||||||
statusCode(HttpStatus.OK.value())
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
paymentAttemptRepository.isSuccessAttemptExists(reservation.id).shouldBeTrue()
|
assertSoftly(reservationRepository.findByIdOrNull(reservationId)!!) {
|
||||||
|
this.status shouldBe ReservationStatus.PAYMENT_IN_PROGRESS
|
||||||
val postOrderTask = postOrderTaskRepository.findAll().first { it.reservationId == reservation.id }
|
|
||||||
assertSoftly(postOrderTask) {
|
|
||||||
it.shouldNotBeNull()
|
|
||||||
it.paymentKey shouldBe paymentRequest.paymentKey
|
|
||||||
it.trial shouldBe 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import com.sangdol.roomescape.supports.ReservationFixture
|
|||||||
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity
|
||||||
import io.kotest.assertions.assertSoftly
|
import io.kotest.assertions.assertSoftly
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.shouldNotBe
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@ -69,7 +70,7 @@ class OrderConcurrencyTest(
|
|||||||
|
|
||||||
test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") {
|
test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") {
|
||||||
every {
|
every {
|
||||||
paymentService.requestConfirm(paymentConfirmRequest)
|
paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
|
||||||
} returns paymentGatewayResponse
|
} returns paymentGatewayResponse
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@ -87,18 +88,13 @@ class OrderConcurrencyTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
|
assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) {
|
||||||
this.status shouldBe ReservationStatus.CONFIRMED
|
this.status shouldNotBe ReservationStatus.EXPIRED
|
||||||
}
|
|
||||||
|
|
||||||
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
|
|
||||||
this.status shouldBe ScheduleStatus.RESERVED
|
|
||||||
this.holdExpiredAt shouldBe null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") {
|
test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") {
|
||||||
every {
|
every {
|
||||||
paymentService.requestConfirm(paymentConfirmRequest)
|
paymentService.requestConfirm(reservation.id, paymentConfirmRequest)
|
||||||
} returns paymentGatewayResponse
|
} returns paymentGatewayResponse
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@ -113,8 +109,6 @@ class OrderConcurrencyTest(
|
|||||||
async {
|
async {
|
||||||
assertThrows<OrderException> {
|
assertThrows<OrderException> {
|
||||||
orderService.confirm(reservation.id, paymentConfirmRequest)
|
orderService.confirm(reservation.id, paymentConfirmRequest)
|
||||||
}.also {
|
|
||||||
it.trial shouldBe 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -157,6 +157,27 @@ class DummyInitializer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createExpiredOrCanceledReservation(
|
||||||
|
user: UserEntity,
|
||||||
|
status: ReservationStatus,
|
||||||
|
storeId: Long = IDGenerator.create(),
|
||||||
|
themeRequest: ThemeCreateRequest = ThemeFixture.createRequest,
|
||||||
|
scheduleRequest: ScheduleCreateRequest = ScheduleFixture.createRequest,
|
||||||
|
reservationRequest: PendingReservationCreateRequest = ReservationFixture.pendingCreateRequest,
|
||||||
|
): ReservationEntity {
|
||||||
|
return createPendingReservation(user, storeId, themeRequest, scheduleRequest, reservationRequest).apply {
|
||||||
|
this.status = status
|
||||||
|
}.also {
|
||||||
|
reservationRepository.save(it)
|
||||||
|
|
||||||
|
scheduleRepository.findByIdOrNull(it.scheduleId)?.let { schedule ->
|
||||||
|
schedule.status = ScheduleStatus.AVAILABLE
|
||||||
|
schedule.holdExpiredAt = null
|
||||||
|
scheduleRepository.save(schedule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createPayment(
|
fun createPayment(
|
||||||
reservationId: Long,
|
reservationId: Long,
|
||||||
request: PaymentConfirmRequest = PaymentFixture.confirmRequest,
|
request: PaymentConfirmRequest = PaymentFixture.confirmRequest,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user