diff --git a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt new file mode 100644 index 00000000..e7fd99ef --- /dev/null +++ b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt @@ -0,0 +1,361 @@ +package roomescape.payment + +import com.ninjasquad.springmockk.MockkBean +import io.kotest.matchers.shouldBe +import io.mockk.every +import org.hamcrest.CoreMatchers.equalTo +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import roomescape.payment.business.PaymentService +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.infrastructure.client.CardDetail +import roomescape.payment.infrastructure.client.EasyPayDetail +import roomescape.payment.infrastructure.client.TosspayClient +import roomescape.payment.infrastructure.client.TransferDetail +import roomescape.payment.infrastructure.common.* +import roomescape.payment.infrastructure.persistence.* +import roomescape.payment.web.PaymentConfirmRequest +import roomescape.payment.web.PaymentCreateResponse +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.util.FunSpecSpringbootTest +import roomescape.util.PaymentFixture +import roomescape.util.runTest + +class PaymentAPITest( + @MockkBean + private val tosspayClient: TosspayClient, + private val paymentService: PaymentService, + private val paymentRepository: PaymentRepository, + private val paymentDetailRepository: PaymentDetailRepository, + private val canceledPaymentRepository: CanceledPaymentRepository +) : FunSpecSpringbootTest() { + init { + context("결제를 승인한다.") { + val amount = 100_000 + context("간편결제 + 카드로 ${amount}원을 결제한다.") { + context("일시불") { + test("토스페이 + 토스뱅크카드(신용)") { + runConfirmTest( + amount = amount, + cardDetail = PaymentFixture.cardDetail( + amount = amount, + issuerCode = CardIssuerCode.TOSS_BANK, + cardType = CardType.CREDIT, + ), + easyPayDetail = PaymentFixture.easypayDetail( + amount = 0, + provider = EasyPayCompanyCode.TOSSPAY + ) + ) + } + + test("삼성페이 + 삼성카드(법인)") { + runConfirmTest( + amount = amount, + cardDetail = PaymentFixture.cardDetail( + amount = amount, + issuerCode = CardIssuerCode.SAMSUNG, + cardType = CardType.CREDIT, + ownerType = CardOwnerType.CORPORATE + ), + easyPayDetail = PaymentFixture.easypayDetail( + amount = 0, + provider = EasyPayCompanyCode.SAMSUNGPAY + ) + ) + } + } + + context("할부") { + val installmentPlanMonths = 12 + test("네이버페이 + 신한카드 / 12개월") { + runConfirmTest( + amount = amount, + cardDetail = PaymentFixture.cardDetail( + amount = amount, + issuerCode = CardIssuerCode.SHINHAN, + installmentPlanMonths = installmentPlanMonths + ), + easyPayDetail = PaymentFixture.easypayDetail( + amount = 0, + provider = EasyPayCompanyCode.NAVERPAY + ) + ) + } + } + + context("간편결제사 포인트 일부 사용") { + val point = (amount * 0.1).toInt() + test("토스페이 + 국민카드(체크) / 일시불 / $point 포인트 사용") { + runConfirmTest( + amount = amount, + cardDetail = PaymentFixture.cardDetail( + amount = (amount - point), + issuerCode = CardIssuerCode.KOOKMIN, + cardType = CardType.CHECK + ), + easyPayDetail = PaymentFixture.easypayDetail( + amount = 0, + provider = EasyPayCompanyCode.TOSSPAY, + discountAmount = point + ) + ) + } + } + } + + context("간편결제사의 선불 충전금액으로 ${amount}원을 결제한다.") { + test("토스페이 + 토스페이머니 / 전액") { + runConfirmTest( + easyPayDetail = PaymentFixture.easypayDetail( + amount = amount, + provider = EasyPayCompanyCode.TOSSPAY + ) + ) + } + + val point = (amount * 0.05).toInt() + + test("카카오페이 + 카카오페이머니 / $point 사용") { + runConfirmTest( + easyPayDetail = PaymentFixture.easypayDetail( + amount = (amount - point), + provider = EasyPayCompanyCode.KAKAOPAY, + discountAmount = point + ) + ) + } + } + + context("계좌이체로 결제한다.") { + test("토스뱅크") { + runConfirmTest( + transferDetail = PaymentFixture.transferDetail(bankCode = BankCode.TOSS_BANK) + ) + } + } + + context("지원하지 않는 결제수단으로 요청시 실패한다.") { + val supportedMethod = listOf( + PaymentMethod.CARD, + PaymentMethod.EASY_PAY, + PaymentMethod.TRANSFER, + ) + + PaymentMethod.entries.filter { it !in supportedMethod }.forEach { + test("결제 수단: ${it.koreanName}") { + val reservation = dummyInitializer.createConfirmReservation( + adminToken = loginUtil.loginAsAdmin(), + reserverToken = loginUtil.loginAsUser() + ) + + val request = PaymentFixture.confirmRequest + + every { + tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) + } returns PaymentFixture.confirmResponse( + paymentKey = request.paymentKey, + amount = request.amount, + method = it, + cardDetail = null, + easyPayDetail = null, + transferDetail = null, + ) + + runTest( + token = loginUtil.loginAsUser(), + using = { + body(PaymentFixture.confirmRequest) + }, + on = { + post("/payments?reservationId=${reservation.id}") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE.errorCode)) + } + ) + } + } + } + } + + context("결제를 취소한다.") { + test("정상 취소") { + val token = loginUtil.loginAsAdmin() + val confirmRequest = PaymentFixture.confirmRequest + val reservation = dummyInitializer.createConfirmReservation( + adminToken = token, + reserverToken = token + ) + + val paymentCreateResponse = createPayment( + request = confirmRequest, + reservationId = reservation.id + ) + + every { + tosspayClient.cancel( + confirmRequest.paymentKey, + confirmRequest.amount, + cancelReason = "cancelReason" + ) + } returns PaymentFixture.cancelResponse(confirmRequest.amount) + + runTest( + token = token, + using = { + val cancelRequest = PaymentFixture.cancelRequest.copy( + reservationId = reservation.id + ) + body(cancelRequest) + }, + on = { + post("/payments/cancel") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val payment = paymentRepository.findByIdOrNull(paymentCreateResponse.paymentId) + ?: throw AssertionError("Unexpected Exception Occurred.") + val canceledPayment = + canceledPaymentRepository.findByPaymentId(paymentCreateResponse.paymentId) + ?: throw AssertionError("Unexpected Exception Occurred.") + + payment.status shouldBe PaymentStatus.CANCELED + canceledPayment.paymentId shouldBe payment.id + canceledPayment.cancelAmount shouldBe payment.totalAmount + } + } + + test("예약에 대한 결제 정보가 없으면 실패한다.") { + val token = loginUtil.loginAsAdmin() + val reservation = dummyInitializer.createConfirmReservation( + adminToken = token, + reserverToken = token, + ) + + runTest( + token = token, + using = { + body(PaymentFixture.cancelRequest.copy(reservationId = reservation.id)) + }, + on = { + post("/payments/cancel") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(PaymentErrorCode.PAYMENT_NOT_FOUND.errorCode)) + } + ) + } + } + } + + private fun createPayment( + request: PaymentConfirmRequest, + reservationId: Long, + ): PaymentCreateResponse { + every { + tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) + } returns PaymentFixture.confirmResponse( + request.paymentKey, + request.amount, + method = PaymentMethod.CARD, + cardDetail = PaymentFixture.cardDetail(request.amount), + easyPayDetail = null, + transferDetail = null, + ) + + return paymentService.confirm(reservationId, request) + } + + fun runConfirmTest( + cardDetail: CardDetail? = null, + easyPayDetail: EasyPayDetail? = null, + transferDetail: TransferDetail? = null, + paymentKey: String = "paymentKey", + amount: Int = 10000, + ) { + + val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) + + val reservation: ReservationEntity = dummyInitializer.createPendingReservation( + adminToken = loginUtil.loginAsAdmin(), + reserverToken = loginUtil.loginAsUser(), + ) + + val method = if (easyPayDetail != null) { + PaymentMethod.EASY_PAY + } else if (cardDetail != null) { + PaymentMethod.CARD + } else if (transferDetail != null) { + PaymentMethod.TRANSFER + } else { + throw AssertionError("결제타입 확인 필요.") + } + + val clientResponse = PaymentFixture.confirmResponse( + paymentKey, amount, method, cardDetail, easyPayDetail, transferDetail + ) + + every { + tosspayClient.confirm(request.paymentKey, request.orderId, request.amount) + } returns clientResponse + + runTest( + token = loginUtil.loginAsUser(), + using = { + body(request) + }, + on = { + post("/payments?reservationId=${reservation.id}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val createdPayment = paymentRepository.findByIdOrNull(it.extract().path("data.paymentId")) + ?: throw AssertionError("Unexpected Exception Occurred.") + val createdPaymentDetail = + paymentDetailRepository.findByIdOrNull(it.extract().path("data.detailId")) + ?: throw AssertionError("Unexpected Exception Occurred.") + + createdPayment.status shouldBe clientResponse.status + createdPayment.method shouldBe clientResponse.method + createdPayment.reservationId shouldBe reservation.id + + when (createdPaymentDetail) { + is PaymentCardDetailEntity -> { + createdPaymentDetail.issuerCode shouldBe clientResponse.card!!.issuerCode + createdPaymentDetail.cardType shouldBe clientResponse.card.cardType + createdPaymentDetail.cardNumber shouldBe clientResponse.card.number + createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount + createdPaymentDetail.vat shouldBe clientResponse.vat + createdPaymentDetail.amount shouldBe (clientResponse.totalAmount - (clientResponse.easyPay?.discountAmount + ?: 0)) + clientResponse.easyPay?.let { easypay -> + createdPaymentDetail.easypayProviderCode shouldBe easypay.provider + createdPaymentDetail.easypayDiscountAmount shouldBe easypay.discountAmount + } + } + + is PaymentBankTransferDetailEntity -> { + createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount + createdPaymentDetail.vat shouldBe clientResponse.vat + createdPaymentDetail.bankCode shouldBe clientResponse.transfer!!.bankCode + createdPaymentDetail.settlementStatus shouldBe clientResponse.transfer.settlementStatus + } + + is PaymentEasypayPrepaidDetailEntity -> { + createdPaymentDetail.suppliedAmount shouldBe clientResponse.suppliedAmount + createdPaymentDetail.vat shouldBe clientResponse.vat + createdPaymentDetail.easypayProviderCode shouldBe clientResponse.easyPay!!.provider + createdPaymentDetail.amount shouldBe clientResponse.easyPay.amount + createdPaymentDetail.discountAmount shouldBe clientResponse.easyPay.discountAmount + } + } + } + } +} diff --git a/src/test/kotlin/roomescape/util/Fixtures.kt b/src/test/kotlin/roomescape/util/Fixtures.kt index 6d15e57f..a4eb0d8a 100644 --- a/src/test/kotlin/roomescape/util/Fixtures.kt +++ b/src/test/kotlin/roomescape/util/Fixtures.kt @@ -3,11 +3,17 @@ package roomescape.util import com.github.f4b6a3.tsid.TsidFactory import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.Role +import roomescape.payment.infrastructure.client.* +import roomescape.payment.infrastructure.common.* +import roomescape.payment.web.PaymentCancelRequest +import roomescape.payment.web.PaymentConfirmRequest +import roomescape.reservation.web.PendingReservationCreateRequest import roomescape.schedule.web.ScheduleCreateRequest import roomescape.theme.infrastructure.persistence.Difficulty import roomescape.theme.web.ThemeCreateRequest import java.time.LocalDate import java.time.LocalTime +import java.time.OffsetDateTime const val INVALID_PK: Long = 9999L val tsidFactory = TsidFactory(0) @@ -53,3 +59,97 @@ object ScheduleFixture { themeId = 1L ) } + +object PaymentFixture { + val confirmRequest: PaymentConfirmRequest = PaymentConfirmRequest( + paymentKey = "paymentKey", + orderId = "orderId", + amount = 10000, + paymentType = PaymentType.NORMAL + ) + + val cancelRequest: PaymentCancelRequest = PaymentCancelRequest( + reservationId = 1L, + cancelReason = "cancelReason", + ) + + fun cardDetail( + amount: Int, + issuerCode: CardIssuerCode = CardIssuerCode.SHINHAN, + cardType: CardType = CardType.CREDIT, + ownerType: CardOwnerType = CardOwnerType.PERSONAL, + installmentPlanMonths: Int = 0, + ): CardDetail = CardDetail( + issuerCode = issuerCode, + number = "429335*********", + amount = amount, + cardType = cardType, + ownerType = ownerType, + isInterestFree = false, + approveNo = "1828382", + installmentPlanMonths = installmentPlanMonths + ) + + fun easypayDetail( + amount: Int, + provider: EasyPayCompanyCode = EasyPayCompanyCode.TOSSPAY, + discountAmount: Int = 0 + ): EasyPayDetail = EasyPayDetail(provider, amount, discountAmount) + + fun transferDetail( + bankCode: BankCode = BankCode.SHINHAN, + settlementStatus: String = "COMPLETED" + ): TransferDetail = TransferDetail( + bankCode = bankCode, + settlementStatus = settlementStatus + ) + + fun confirmResponse( + paymentKey: String, + amount: Int, + method: PaymentMethod, + cardDetail: CardDetail?, + easyPayDetail: EasyPayDetail?, + transferDetail: TransferDetail? + ) = PaymentClientConfirmResponse( + paymentKey = paymentKey, + status = PaymentStatus.DONE, + totalAmount = amount, + vat = (amount * 0.1).toInt(), + suppliedAmount = (amount * 0.9).toInt(), + method = method, + card = cardDetail, + easyPay = easyPayDetail, + transfer = transferDetail, + requestedAt = OffsetDateTime.now(), + approvedAt = OffsetDateTime.now().plusSeconds(5) + ) + + fun cancelResponse( + amount: Int, + cardDiscountAmount: Int = 0, + transferDiscountAmount: Int = 0, + easypayDiscountAmount: Int = 0, + cancelReason: String = "cancelReason" + ) = PaymentClientCancelResponse( + status = PaymentStatus.CANCELED, + cancels = CancelDetail( + cancelAmount = amount, + cardDiscountAmount = cardDiscountAmount, + transferDiscountAmount = transferDiscountAmount, + easyPayDiscountAmount = easypayDiscountAmount, + canceledAt = OffsetDateTime.now().plusSeconds(5), + cancelReason = cancelReason + ), + ) +} + +object ReservationFixture { + val pendingCreateRequest: PendingReservationCreateRequest = PendingReservationCreateRequest( + scheduleId = 1L, + reserverName = "Wilbur Stuart", + reserverContact = "wilbur@example.com", + participantCount = 5, + requirement = "Hello, Nice to meet you!" + ) +}