diff --git a/src/test/kotlin/roomescape/payment/SampleTosspayConstant.kt b/src/test/kotlin/roomescape/payment/SampleTosspayConstant.kt new file mode 100644 index 00000000..e54745f7 --- /dev/null +++ b/src/test/kotlin/roomescape/payment/SampleTosspayConstant.kt @@ -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() +} diff --git a/src/test/kotlin/roomescape/payment/TosspayClientTest.kt b/src/test/kotlin/roomescape/payment/TosspayClientTest.kt new file mode 100644 index 00000000..88f7ad66 --- /dev/null +++ b/src/test/kotlin/roomescape/payment/TosspayClientTest.kt @@ -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 { + 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 { + 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) + } + } + } + } +}