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

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

View File

@ -0,0 +1,165 @@
package roomescape.payment
import java.time.OffsetDateTime
object SampleTosspayConstant {
const val PAYMENT_KEY: String = "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1"
const val ORDER_ID: String = "MC4wODU4ODQwMzg4NDk0"
const val AMOUNT: Int = 100_000
const val CANCEL_REASON: String = "테스트 결제 취소"
val cancelRequestJson: String = """
{
"cancelReason": "$CANCEL_REASON"
}
""".trimIndent()
val tossPaymentErrorJson: String = """
{
"code": "ERROR_CODE",
"message": "Error message"
}
""".trimIndent()
val confirmJson: String = """
{
"mId": "tgen_docs",
"lastTransactionKey": "txrd_a01k4mtanmyx0hm6hmmbvfvmhky",
"paymentKey": "$PAYMENT_KEY",
"orderId": "$ORDER_ID",
"orderName": "Sonya Aguirre 예약 결제",
"taxExemptionAmount": 0,
"status": "DONE",
"requestedAt": "${OffsetDateTime.now()}",
"approvedAt": "${OffsetDateTime.now().plusSeconds(5)}",
"useEscrow": false,
"cultureExpense": false,
"card": {
"issuerCode": "41",
"acquirerCode": "41",
"number": "12748273****675*",
"installmentPlanMonths": 0,
"isInterestFree": false,
"interestPayer": null,
"approveNo": "00000000",
"useCardPoint": false,
"cardType": "신용",
"ownerType": "개인",
"acquireStatus": "READY",
"amount": $AMOUNT
},
"virtualAccount": null,
"transfer": null,
"mobilePhone": null,
"giftCertificate": null,
"cashReceipt": null,
"cashReceipts": null,
"discount": null,
"cancels": null,
"secret": "ps_6BYq7GWPVvGNAwZeRBDLrNE5vbo1",
"type": "NORMAL",
"easyPay": {
"provider": "토스페이",
"amount": 0,
"discountAmount": 0
},
"country": "KR",
"failure": null,
"isPartialCancelable": true,
"receipt": {
"url": "https://dashboard-sandbox.tosspayments.com/receipt/redirection?transactionId=tgen_20250908230510v2rY2&ref=PX"
},
"checkout": {
"url": "https://api.tosspayments.com/v1/payments/tgen_20250908230510v2rY2/checkout"
},
"currency": "KRW",
"totalAmount": $AMOUNT,
"balanceAmount": $AMOUNT,
"suppliedAmount": ${(AMOUNT * 0.9).toInt()},
"vat": ${(AMOUNT * 0.1).toInt()},
"taxFreeAmount": 0,
"method": "간편결제",
"version": "2022-11-16",
"metadata": null
}
""".trimIndent()
val cancelJson: String = """
{
"mId": "tgen_docs",
"lastTransactionKey": "txrd_a01k4mtgh26vgrn1evbdckyqmdr",
"paymentKey": "$PAYMENT_KEY",
"orderId": "$ORDER_ID",
"orderName": "Sonya Aguirre 예약 결제",
"taxExemptionAmount": 0,
"status": "CANCELED",
"requestedAt": "${OffsetDateTime.now()}",
"approvedAt": "${OffsetDateTime.now().plusSeconds(5)}",
"useEscrow": false,
"cultureExpense": false,
"card": {
"issuerCode": "41",
"acquirerCode": "41",
"number": "12748273****675*",
"installmentPlanMonths": 0,
"isInterestFree": false,
"interestPayer": null,
"approveNo": "00000000",
"useCardPoint": false,
"cardType": "신용",
"ownerType": "개인",
"acquireStatus": "READY",
"amount": $AMOUNT
},
"virtualAccount": null,
"transfer": null,
"mobilePhone": null,
"giftCertificate": null,
"cashReceipt": null,
"cashReceipts": null,
"discount": null,
"cancels": [
{
"transactionKey": "txrd_a01k4mtgh26vgrn1evbdckyqmdr",
"cancelReason": "$CANCEL_REASON",
"taxExemptionAmount": 0,
"canceledAt": "${OffsetDateTime.now().plusMinutes(1)}",
"cardDiscountAmount": 0,
"transferDiscountAmount": 0,
"easyPayDiscountAmount": 0,
"receiptKey": null,
"cancelStatus": "DONE",
"cancelRequestId": null,
"cancelAmount": $AMOUNT,
"taxFreeAmount": 0,
"refundableAmount": 0
}
],
"secret": "ps_6BYq7GWPVvGNAwZeRBDLrNE5vbo1",
"type": "NORMAL",
"easyPay": {
"provider": "토스페이",
"amount": 0,
"discountAmount": 0
},
"country": "KR",
"failure": null,
"isPartialCancelable": true,
"receipt": {
"url": "https://dashboard-sandbox.tosspayments.com/receipt/redirection?transactionId=tgen_20250908230510v2rY2&ref=PX"
},
"checkout": {
"url": "https://api.tosspayments.com/v1/payments/tgen_20250908230510v2rY2/checkout"
},
"currency": "KRW",
"totalAmount": ${AMOUNT},
"balanceAmount": 0,
"suppliedAmount": 0,
"vat": 0,
"taxFreeAmount": 0,
"method": "간편결제",
"version": "2022-11-16",
"metadata": null
}
""".trimIndent()
}

View File

@ -0,0 +1,159 @@
package roomescape.payment
import com.ninjasquad.springmockk.MockkBean
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.ResponseActions
import org.springframework.test.web.client.match.MockRestRequestMatchers.*
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.client.PaymentClientCancelResponse
import roomescape.payment.infrastructure.client.PaymentClientConfirmResponse
import roomescape.payment.infrastructure.client.TosspayClient
import roomescape.payment.infrastructure.common.PaymentStatus
@RestClientTest(TosspayClient::class)
@MockkBean(JpaMetamodelMappingContext::class)
class TosspayClientTest(
@Autowired val client: TosspayClient,
@Autowired val mockServer: MockRestServiceServer
) : FunSpec() {
init {
context("결제 승인 요청") {
fun commonAction(): ResponseActions = mockServer.expect {
requestTo("/v1/payments/confirm")
}.andExpect {
method(HttpMethod.POST)
}.andExpect {
content().contentType(MediaType.APPLICATION_JSON)
}.andExpect {
content().json(SampleTosspayConstant.confirmJson)
}
test("성공 응답") {
commonAction().andRespond {
withSuccess()
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTosspayConstant.confirmJson)
.createResponse(it)
}
val paymentResponse: PaymentClientConfirmResponse = client.confirm(
SampleTosspayConstant.PAYMENT_KEY,
SampleTosspayConstant.ORDER_ID,
SampleTosspayConstant.AMOUNT
)
assertSoftly(paymentResponse) {
this.paymentKey shouldBe SampleTosspayConstant.PAYMENT_KEY
this.totalAmount shouldBe SampleTosspayConstant.AMOUNT
}
}
context("실패 응답") {
fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) {
commonAction().andRespond {
withStatus(httpStatus)
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTosspayConstant.tossPaymentErrorJson)
.createResponse(it)
}
val exception = shouldThrow<PaymentException> {
client.confirm(
SampleTosspayConstant.PAYMENT_KEY,
SampleTosspayConstant.ORDER_ID,
SampleTosspayConstant.AMOUNT
)
}
exception.errorCode shouldBe expectedError
}
test("결제 서버에서 4XX 응답 시") {
runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR)
}
test("결제 서버에서 5XX 응답 시") {
runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
}
}
}
context("결제 취소 요청") {
fun commonAction(): ResponseActions = mockServer.expect {
requestTo("/v1/payments/${SampleTosspayConstant.PAYMENT_KEY}/cancel")
}.andExpect {
method(HttpMethod.POST)
}.andExpect {
content().contentType(MediaType.APPLICATION_JSON)
}.andExpect {
content().json(SampleTosspayConstant.cancelRequestJson)
}
test("성공 응답") {
commonAction().andRespond {
withSuccess()
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTosspayConstant.cancelJson)
.createResponse(it)
}
val cancelResponse: PaymentClientCancelResponse = client.cancel(
SampleTosspayConstant.PAYMENT_KEY,
SampleTosspayConstant.AMOUNT,
SampleTosspayConstant.CANCEL_REASON
)
assertSoftly(cancelResponse) {
this.status shouldBe PaymentStatus.CANCELED
with (this.cancels) {
this.cancelAmount shouldBe SampleTosspayConstant.AMOUNT
this.cancelReason shouldBe SampleTosspayConstant.CANCEL_REASON
}
}
}
context("실패 응답") {
fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) {
commonAction().andRespond {
withStatus(httpStatus)
.contentType(MediaType.APPLICATION_JSON)
.body(SampleTosspayConstant.tossPaymentErrorJson)
.createResponse(it)
}
val exception = shouldThrow<PaymentException> {
client.cancel(
SampleTosspayConstant.PAYMENT_KEY,
SampleTosspayConstant.AMOUNT,
SampleTosspayConstant.CANCEL_REASON
)
}
exception.errorCode shouldBe expectedError
}
test("결제 서버에서 4XX 응답 시") {
runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR)
}
test("결제 서버에서 5XX 응답 시") {
runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
}
}
}
}
}