test: 변경된 OrderService의 확정 로직 테스트 반영

This commit is contained in:
이상진 2025-10-16 15:29:49 +09:00
parent 385f98fb21
commit b636ac926e
3 changed files with 71 additions and 114 deletions

View File

@ -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
} }
} }
} }

View File

@ -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
} }
} }
} }

View File

@ -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,