diff --git a/docs/20261103_결제완료응답확장/20261103_charge_first_charged_ddl.sql b/docs/20261103_결제완료응답확장/20261103_charge_first_charged_ddl.sql new file mode 100644 index 00000000..93191b8b --- /dev/null +++ b/docs/20261103_결제완료응답확장/20261103_charge_first_charged_ddl.sql @@ -0,0 +1,2 @@ +ALTER TABLE charge + ADD COLUMN is_first_charged BIT(1) NULL COMMENT '최초 유료 충전 여부, 최초 완료 전환에서만 확정'; diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt index 31630633..9198ed22 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.member.Member import javax.persistence.CascadeType +import javax.persistence.Column import javax.persistence.Entity import javax.persistence.EnumType import javax.persistence.Enumerated @@ -44,4 +45,7 @@ data class Charge( var title: String? = null var googleProductId: String? = null + + @Column(name = "is_first_charged") + var isFirstCharged: Boolean? = null } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt index 8083f131..0ec64366 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeCompleteResponse.kt @@ -5,5 +5,6 @@ import java.math.BigDecimal data class ChargeCompleteResponse( val price: BigDecimal, val currencyCode: String, - val isFirstCharged: Boolean + val isFirstCharged: Boolean, + val isIap: Boolean ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt index 86e1d615..63e0c92b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt @@ -26,7 +26,7 @@ interface ChargeQueryRepository { fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge? fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge? fun getChargeCountAfterDate(memberId: Long, date: LocalDateTime): Int - fun isFirstCharged(memberId: Long): Boolean + fun existsSuccessfulPaidCharge(memberId: Long, excludedChargeId: Long): Boolean fun getChargeCount(memberId: Long): Int } @@ -86,19 +86,24 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha .size } - override fun isFirstCharged(memberId: Long): Boolean { + override fun existsSuccessfulPaidCharge(memberId: Long, excludedChargeId: Long): Boolean { return queryFactory - .select(charge.id) + .selectOne() .from(charge) - .innerJoin(charge.member, member) .innerJoin(charge.payment, payment) .where( - member.id.eq(memberId), - charge.status.eq(ChargeStatus.CHARGE), - payment.status.eq(PaymentStatus.COMPLETE) + charge.member.id.eq(memberId), + charge.id.ne(excludedChargeId), + payment.paymentGateway.`in`( + PaymentGateway.PG, + PaymentGateway.PAYVERSE, + PaymentGateway.APPLE_IAP, + PaymentGateway.GOOGLE_IAP + ), + charge.status.eq(ChargeStatus.CHARGE).and(payment.status.eq(PaymentStatus.COMPLETE)) + .or(charge.status.eq(ChargeStatus.CANCEL).and(payment.status.eq(PaymentStatus.RETURN))) ) - .fetch() - .size <= 1 + .fetchFirst() != null } override fun getChargeCount(memberId: Long): Int { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt index 192f50bf..e6e6eeb7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt @@ -32,6 +32,7 @@ import org.springframework.http.HttpHeaders import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Retryable import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Isolation import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal import java.math.RoundingMode @@ -98,7 +99,7 @@ class ChargeService( private val serverEnv: String ) { - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) fun payverseWebhook(request: PayverseWebhookRequest): Boolean { val chargeId = request.orderId.toLongOrNull() ?: return false val charge = chargeRepository.findByIdForUpdate(chargeId) ?: return false @@ -142,6 +143,8 @@ class ChargeService( request.sign == expectedSign if (isSuccess) { + val member = memberRepository.findByIdForUpdate(charge.member!!.id!!) ?: return false + recordFirstFact(charge, member.id!!) // payverseVerify의 226~246 라인과 동일 처리 charge.payment?.receiptId = request.tid val mappedMethod = if (request.schemeGroup == "PVKR") { @@ -153,7 +156,6 @@ class ChargeService( charge.payment?.status = PaymentStatus.COMPLETE charge.payment?.locale = request.requestCurrency - val member = memberRepository.findByIdForUpdate(charge.member!!.id!!) ?: return false member.charge(charge.chargeCan, charge.rewardCan, "pg") chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) true @@ -300,10 +302,13 @@ class ChargeService( return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson) } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse { val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong()) ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") + if (charge.member?.id != memberId) { + throw SodaException(messageKey = "can.charge.invalid_payment_info") + } val member = memberRepository.findByIdForUpdate(memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") @@ -354,6 +359,7 @@ class ChargeService( verifyResponse.requestAmount.compareTo(expectedAmount) == 0 if (isSuccess) { + recordFirstFact(charge, member.id!!) // verify 함수의 232~248 라인과 동일 처리 charge.payment?.receiptId = verifyResponse.tid val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") { @@ -369,11 +375,7 @@ class ChargeService( member.charge(charge.chargeCan, charge.rewardCan, "pg") chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) - return ChargeCompleteResponse( - price = charge.payment!!.price, - currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", - isFirstCharged = chargeRepository.isFirstCharged(memberId) - ) + return completeResponse(charge) } else { throw SodaException(messageKey = "can.charge.invalid_payment_info") } @@ -384,11 +386,7 @@ class ChargeService( PaymentStatus.COMPLETE -> { // 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환 - return ChargeCompleteResponse( - price = charge.payment!!.price, - currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", - isFirstCharged = chargeRepository.isFirstCharged(memberId) - ) + return completeResponse(charge) } else -> { @@ -416,19 +414,22 @@ class ChargeService( return ChargeResponse(chargeId = charge.id!!) } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse { val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong()) ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") + if (charge.member?.id != memberId) { + throw SodaException(messageKey = "can.charge.invalid_payment_info") + } val member = memberRepository.findByIdForUpdate(memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.paymentGateway == PaymentGateway.PG) { if (charge.payment?.status == PaymentStatus.COMPLETE) { - return completeResponse(charge, memberId) + return completeResponse(charge) } - val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey) + val bootpay = createBootpay(bootpayApplicationId, bootpayPrivateKey) try { bootpay.accessToken @@ -438,13 +439,14 @@ class ChargeService( ) if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) { + recordFirstFact(charge, member.id!!) charge.payment?.receiptId = verifyResult.receiptId charge.payment?.method = verifyResult.method charge.payment?.status = PaymentStatus.COMPLETE member.charge(charge.chargeCan, charge.rewardCan, "pg") chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) - return completeResponse(charge, memberId) + return completeResponse(charge) } else { throw SodaException(messageKey = "can.charge.invalid_payment_info") } @@ -456,19 +458,22 @@ class ChargeService( } } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse { val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong()) ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") + if (charge.member?.id != memberId) { + throw SodaException(messageKey = "can.charge.invalid_payment_info") + } val member = memberRepository.findByIdForUpdate(memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.paymentGateway == PaymentGateway.PG) { if (charge.payment?.status == PaymentStatus.COMPLETE) { - return completeResponse(charge, memberId) + return completeResponse(charge) } - val bootpay = Bootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey) + val bootpay = createBootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey) try { bootpay.accessToken @@ -478,6 +483,7 @@ class ChargeService( ) if (verifyResult.status == 1) { + recordFirstFact(charge, member.id!!) charge.payment?.receiptId = verifyResult.receiptId charge.payment?.method = if (verifyResult.pg.contains("카카오")) { "${verifyResult.pg}-${verifyResult.method}" @@ -488,7 +494,7 @@ class ChargeService( member.charge(charge.chargeCan, charge.rewardCan, "pg") chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) - return completeResponse(charge, memberId) + return completeResponse(charge) } else { throw SodaException(messageKey = "can.charge.invalid_payment_info") } @@ -522,20 +528,24 @@ class ChargeService( return ChargeResponse(chargeId = charge.id!!) } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse { val charge = chargeRepository.findByIdForUpdate(verifyRequest.chargeId) ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") + if (charge.member?.id != memberId) { + throw SodaException(messageKey = "can.charge.invalid_payment_info") + } val member = memberRepository.findByIdForUpdate(memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) { if (charge.payment?.status == PaymentStatus.COMPLETE) { - return completeResponse(charge, memberId) + return completeResponse(charge) } // 검증로직 if (requestRealServerVerify(verifyRequest)) { + recordFirstFact(charge, member.id!!) charge.payment?.receiptId = verifyRequest.receiptString charge.payment?.method = messageSource .getMessage("can.charge.payment_method.apple_iap", langContext.lang) @@ -544,7 +554,7 @@ class ChargeService( member.charge(charge.chargeCan, charge.rewardCan, "ios") chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) - return completeResponse(charge, memberId) + return completeResponse(charge) } else { throw SodaException(messageKey = "can.charge.invalid_payment_info") } @@ -583,7 +593,7 @@ class ChargeService( return charge.id!! } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) fun processGoogleIap( memberId: Long, chargeId: Long, @@ -592,23 +602,27 @@ class ChargeService( ): ChargeCompleteResponse { val charge = chargeRepository.findByIdForUpdate(chargeId) ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") + if (charge.member?.id != memberId) { + throw SodaException(messageKey = "can.charge.invalid_payment_info") + } val member = memberRepository.findByIdForUpdate(memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.status == PaymentStatus.REQUEST) { val orderId = verifyPurchase(purchaseToken, productId) if (orderId.isNotBlank()) { + recordFirstFact(charge, member.id!!) charge.payment!!.orderId = orderId charge.payment!!.status = PaymentStatus.COMPLETE member.charge(charge.chargeCan, 0, "aos") chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) - return completeResponse(charge, memberId) + return completeResponse(charge) } else { throw SodaException(messageKey = "can.charge.purchase_failed_contact") } } else if (charge.payment!!.status == PaymentStatus.COMPLETE) { - return completeResponse(charge, memberId) + return completeResponse(charge) } else { throw SodaException(messageKey = "can.charge.invalid_payment_info") } @@ -690,11 +704,23 @@ class ChargeService( return String.format(template, *args) } - private fun completeResponse(charge: Charge, memberId: Long): ChargeCompleteResponse { + internal fun createBootpay(applicationId: String, privateKey: String): Bootpay { + return Bootpay(applicationId, privateKey) + } + + private fun recordFirstFact(charge: Charge, memberId: Long) { + if (charge.payment?.status == PaymentStatus.REQUEST && charge.isFirstCharged == null) { + charge.isFirstCharged = !chargeRepository.existsSuccessfulPaidCharge(memberId, charge.id!!) + } + } + + private fun completeResponse(charge: Charge): ChargeCompleteResponse { return ChargeCompleteResponse( price = charge.payment!!.price, currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", - isFirstCharged = chargeRepository.isFirstCharged(memberId) + isFirstCharged = charge.isFirstCharged == true, + isIap = charge.payment!!.paymentGateway == PaymentGateway.GOOGLE_IAP || + charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepositoryTest.kt new file mode 100644 index 00000000..f3192d6d --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepositoryTest.kt @@ -0,0 +1,120 @@ +package kr.co.vividnext.sodalive.can.charge + +import kr.co.vividnext.sodalive.can.payment.Payment +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import org.hibernate.dialect.MySQL8Dialect +import org.hibernate.type.BooleanType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.NullSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import(QueryDslConfig::class) +class ChargeRepositoryTest @Autowired constructor( + private val entityManager: EntityManager, + private val repository: ChargeRepository +) { + @Test + @DisplayName("최초 유료 충전 fact는 엔티티에 저장되는 속성이다") + fun shouldMapFirstFactAsPersistentAttribute() { + assertTrue(entityManager.metamodel.entity(Charge::class.java).attributes.any { it.name == "isFirstCharged" }) + val column = Charge::class.java.getDeclaredField("isFirstCharged").getAnnotation(Column::class.java) + assertEquals("is_first_charged", column.name) + assertEquals(true, column.nullable) + assertEquals("bit", MySQL8Dialect().getTypeName(BooleanType.INSTANCE.sqlType())) + } + + @ParameterizedTest + @NullSource + @ValueSource(booleans = [true, false]) + @DisplayName("nullable 최초 fact의 null true false가 DB 왕복 후 보존된다") + fun shouldRoundTripNullableFirstFact(marker: Boolean?) { + val owner = owner() + val charge = saveCharge(owner, ChargeStatus.CHARGE, PaymentStatus.REQUEST, PaymentGateway.PG).apply { + isFirstCharged = marker + } + entityManager.flush() + entityManager.clear() + assertEquals(marker, repository.findById(charge.id!!).orElseThrow().isFirstCharged) + } + + @ParameterizedTest + @MethodSource("historyCases") + @DisplayName("유료 이력은 네 gateway와 정확한 충전 완료 또는 취소 환불 쌍만 인정한다") + fun shouldFindOnlyOtherSameMemberPaidChargeOrReturnedCancellation( + chargeStatus: ChargeStatus, + paymentStatus: PaymentStatus, + gateway: PaymentGateway, + expected: Boolean + ) { + val owner = owner() + val current = saveCharge(owner, ChargeStatus.CHARGE, PaymentStatus.COMPLETE, PaymentGateway.PG) + saveCharge(owner, chargeStatus, paymentStatus, gateway) + entityManager.flush() + entityManager.clear() + assertEquals(expected, repository.existsSuccessfulPaidCharge(owner.id!!, current.id!!)) + } + + @Test + @DisplayName("현재 charge와 다른 회원 이력은 제외하고 생성 순서 잔액 marker로 이력을 제한하지 않는다") + fun shouldExcludeCurrentAndOtherMembersWithoutOrderingOrMarkerFilters() { + val owner = owner() + val current = saveCharge(owner, ChargeStatus.CHARGE, PaymentStatus.COMPLETE, PaymentGateway.PG) + saveCharge(owner(), ChargeStatus.CHARGE, PaymentStatus.COMPLETE, PaymentGateway.APPLE_IAP) + assertEquals(false, repository.existsSuccessfulPaidCharge(owner.id!!, current.id!!)) + val later = saveCharge(owner, ChargeStatus.CANCEL, PaymentStatus.RETURN, PaymentGateway.PAYVERSE) + later.createdAt = LocalDateTime.of(2020, 1, 1, 0, 0) + current.createdAt = LocalDateTime.of(2030, 1, 1, 0, 0) + entityManager.flush() + entityManager.clear() + assertEquals(true, repository.existsSuccessfulPaidCharge(owner.id!!, current.id!!)) + } + + private fun owner(): Member { + return Member(password = "password", nickname = "history-owner").also { entityManager.persist(it) } + } + + private fun saveCharge(owner: Member, status: ChargeStatus, paymentStatus: PaymentStatus, gateway: PaymentGateway): Charge { + return repository.save( + Charge(0, 0, status).apply { + member = owner + payment = Payment(paymentStatus, gateway) + } + ) + } + + companion object { + @JvmStatic + fun historyCases(): List { + val pairs = listOf( + Triple(ChargeStatus.CHARGE, PaymentStatus.COMPLETE, true), + Triple(ChargeStatus.CANCEL, PaymentStatus.RETURN, true), + Triple(ChargeStatus.CHARGE, PaymentStatus.RETURN, false), + Triple(ChargeStatus.CANCEL, PaymentStatus.COMPLETE, false), + Triple(ChargeStatus.CHARGE, PaymentStatus.REQUEST, false), + Triple(ChargeStatus.CANCEL, PaymentStatus.REQUEST, false) + ) + listOf(ChargeStatus.EVENT, ChargeStatus.COUPON, ChargeStatus.ADMIN, ChargeStatus.ADS, ChargeStatus.REFUND_CHARGE) + .flatMap { status -> PaymentStatus.values().map { Triple(status, it, false) } } + return pairs.flatMap { (status, payment, paid) -> + listOf(PaymentGateway.PG, PaymentGateway.PAYVERSE, PaymentGateway.APPLE_IAP, PaymentGateway.GOOGLE_IAP) + .map { Arguments.of(status, payment, it, paid) } + + Arguments.of(status, payment, PaymentGateway.POINT_CLICK_AD, false) + } + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceIntegrationTest.kt new file mode 100644 index 00000000..66244fa9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeServiceIntegrationTest.kt @@ -0,0 +1,576 @@ +package kr.co.vividnext.sodalive.can.charge + +import kr.co.bootpay.Bootpay +import kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundRequest +import kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundService +import kr.co.vividnext.sodalive.can.Can +import kr.co.vividnext.sodalive.can.CanStatus +import kr.co.vividnext.sodalive.can.payment.Payment +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.google.GooglePlayService +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobRepository +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobService +import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.apache.commons.codec.digest.DigestUtils +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.mock.mockito.SpyBean +import org.springframework.core.env.Environment +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronizationManager +import org.springframework.transaction.support.TransactionTemplate +import java.math.BigDecimal +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:charge-service-integration;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "apple.iap-verify-url=https://apple.invalid/verifyReceipt", + "apple.iap-verify-sandbox-url=https://apple-sandbox.invalid/verifyReceipt", + "payverse.host=https://payverse.invalid", + "payverse.usd-mid=t2-test-mid", + "payverse.usd-secret-key=t2-test-secret" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class ChargeServiceIntegrationTest @Autowired constructor( + private val chargeRepository: ChargeRepository, + private val memberRepository: MemberRepository, + private val jobRepository: ChargeEventJobRepository, + private val transactionTemplate: TransactionTemplate, + private val entityManager: EntityManager, + private val environment: Environment, + private val refundService: AdminChargeRefundService +) { + @SpyBean + private lateinit var service: ChargeService + + @MockBean + private lateinit var okHttpClient: OkHttpClient + + @MockBean + private lateinit var googlePlayService: GooglePlayService + + @SpyBean + private lateinit var chargeEventJobService: ChargeEventJobService + + private lateinit var bootpay: Bootpay + + @BeforeEach + fun setUp() { + val blockedRequest = Request.Builder().url("https://network-blocked.invalid").build() + Mockito.doThrow(AssertionError("외부 HTTP 호출 금지")) + .`when`(okHttpClient).newCall(Mockito.any(Request::class.java) ?: blockedRequest) + Mockito.doThrow(AssertionError("실제 Google 구매 검증 금지")) + .`when`(googlePlayService).verifyAndConsumePurchase(Mockito.anyString(), Mockito.anyString()) + bootpay = Mockito.mock(Bootpay::class.java) + Mockito.doThrow(AssertionError("실제 Bootpay 토큰 요청 금지")).`when`(bootpay).accessToken + Mockito.doThrow(AssertionError("실제 Bootpay 영수증 조회 금지")) + .`when`(bootpay).getReceipt(Mockito.anyString()) + Mockito.doReturn(bootpay).`when`(service).createBootpay(Mockito.anyString(), Mockito.anyString()) + } + + @ParameterizedTest + @EnumSource(VerificationPath::class) + @DisplayName("타인 소유 완료 충전은 다섯 검증 경로에서 완료 shortcut보다 먼저 거부된다") + fun shouldRejectForeignCompletedChargeBeforeShortcut(path: VerificationPath) { + val fixture = createFixture(path.gateway, PaymentStatus.COMPLETE) + val before = storedState(fixture) + + assertAll( + { + val exception = assertThrows(SodaException::class.java) { + verify(path, fixture.requesterId, fixture.chargeId) + } + assertEquals("can.charge.invalid_payment_info", exception.messageKey) + }, + { assertEquals(before, storedState(fixture)) }, + { assertNoProviderOrEventCalls() } + ) + } + + @ParameterizedTest + @EnumSource(VerificationPath::class) + @DisplayName("타인 소유 요청 충전은 다섯 검증 경로에서 provider 호출과 변경 전에 거부된다") + fun shouldRejectForeignPendingChargeBeforeProviderOrMutation(path: VerificationPath) { + val fixture = createFixture(path.gateway, PaymentStatus.REQUEST) + val before = storedState(fixture) + + assertAll( + { + val exception = assertThrows(SodaException::class.java) { + verify(path, fixture.requesterId, fixture.chargeId) + } + assertEquals("can.charge.invalid_payment_info", exception.messageKey) + }, + { assertEquals(before, storedState(fixture)) }, + { assertNoProviderOrEventCalls() } + ) + } + + @ParameterizedTest + @EnumSource(VerificationPath::class) + @DisplayName("본인 소유 완료 충전 재조회는 상태와 잔액을 중복 변경하지 않는다") + fun shouldReplayOwnCompletedChargeWithoutMutation(path: VerificationPath) { + val fixture = createFixture(path.gateway, PaymentStatus.COMPLETE) + val before = storedState(fixture) + + val response = verify(path, fixture.ownerId, fixture.chargeId) + + assertAll( + { assertEquals(0, BigDecimal("9.99").compareTo(response.price)) }, + { assertEquals("USD", response.currencyCode) }, + { assertEquals(false, response.isFirstCharged) }, + { assertEquals(before, storedState(fixture)) }, + { assertNoProviderOrEventCalls() } + ) + } + + @ParameterizedTest + @EnumSource(VerificationPath::class) + @DisplayName("첫 완료 fact는 저장되고 이후 구매 뒤에도 첫 결제 응답에 유지된다") + fun shouldPersistFirstFactOnlyForFirstPaidCompletion(path: VerificationPath) { + val fixture = createFixture(path.gateway, PaymentStatus.REQUEST) + enableSuccessfulProvider(path, fixture) + assertEquals(true, verify(path, fixture.ownerId, fixture.chargeId).isFirstCharged) + assertEquals(true, storedState(fixture).firstFact) + val second = addCharge(fixture, PaymentGateway.GOOGLE_IAP) + enableSuccessfulProvider(VerificationPath.GOOGLE, second) + assertEquals(false, verify(VerificationPath.GOOGLE, second.ownerId, second.chargeId).isFirstCharged) + assertEquals(false, storedState(second).firstFact) + val beforeReplay = storedState(fixture) + assertEquals(true, verify(path, fixture.ownerId, fixture.chargeId).isFirstCharged) + assertEquals(beforeReplay, storedState(fixture)) + } + + @ParameterizedTest + @MethodSource("paidHistories") + @DisplayName("과거 완료 null과 환불 null은 gateway가 달라도 다음 최초 fact를 false로 확정한다") + fun shouldRespectLegacyPaidHistory(gateway: PaymentGateway, returned: Boolean) { + val history = createFixture(gateway, if (returned) PaymentStatus.RETURN else PaymentStatus.COMPLETE) + if (returned) { + transactionTemplate.executeWithoutResult { + chargeRepository.findById(history.chargeId).orElseThrow().status = ChargeStatus.CANCEL + } + } + val next = addCharge(history, PaymentGateway.GOOGLE_IAP) + enableSuccessfulProvider(VerificationPath.GOOGLE, next) + assertEquals(false, verify(VerificationPath.GOOGLE, next.ownerId, next.chargeId).isFirstCharged) + assertEquals(false, storedState(next).firstFact) + assertEquals(null, storedState(history).firstFact) + } + + @ParameterizedTest + @EnumSource(VerificationPath::class) + @DisplayName("과거 완료 null은 보존하고 실제 관리자 환불 후에도 확정된 최초 fact는 보존한다") + fun shouldPreserveLegacyNullOnReplayAndPersistFactAcrossRefund(path: VerificationPath) { + val legacy = createFixture(path.gateway, PaymentStatus.COMPLETE) + assertEquals(false, verify(path, legacy.ownerId, legacy.chargeId).isFirstCharged) + assertEquals(null, storedState(legacy).firstFact) + val first = createFixture(path.gateway, PaymentStatus.REQUEST) + enableSuccessfulProvider(path, first) + assertEquals(true, verify(path, first.ownerId, first.chargeId).isFirstCharged) + refundService.refund(AdminChargeRefundRequest(first.chargeId)) + val refunded = storedState(first) + assertEquals(ChargeStatus.CANCEL, refunded.chargeStatus) + assertEquals(PaymentStatus.RETURN, refunded.paymentStatus) + assertEquals(true, refunded.firstFact) + val next = addCharge(first, PaymentGateway.GOOGLE_IAP) + enableSuccessfulProvider(VerificationPath.GOOGLE, next) + assertEquals(false, verify(VerificationPath.GOOGLE, next.ownerId, next.chargeId).isFirstCharged) + assertEquals(true, storedState(first).firstFact) + } + + @ParameterizedTest + @MethodSource("refundedReverifications") + @DisplayName("기존 PG Hecto Apple 환불 재검증 동작은 유지하되 marker는 재계산하지 않는다") + fun shouldNotRecomputeFactForNonRequestReverification(path: VerificationPath, marker: Boolean?) { + val fixture = createFixture(path.gateway, PaymentStatus.RETURN) + transactionTemplate.executeWithoutResult { + chargeRepository.findById(fixture.chargeId).orElseThrow().isFirstCharged = marker + } + enableSuccessfulProvider(path, fixture) + assertEquals(marker == true, verify(path, fixture.ownerId, fixture.chargeId).isFirstCharged) + assertEquals(marker, storedState(fixture).firstFact) + } + + @ParameterizedTest + @EnumSource(VerificationPath::class) + @DisplayName("완료 후 이벤트 처리 실패는 최초 fact 결제 상태와 잔액을 함께 rollback한다") + fun shouldRollBackFirstFactWhenCompletionFails(path: VerificationPath) { + val fixture = createFixture(path.gateway, PaymentStatus.REQUEST) + enableSuccessfulProvider(path, fixture) + val before = storedState(fixture) + failAfterCompletion(fixture) + assertThrows(RuntimeException::class.java) { verify(path, fixture.ownerId, fixture.chargeId) } + assertEquals(before, storedState(fixture)) + } + + @Test + @DisplayName("Payverse webhook 최초 완료는 fact를 저장하고 이후 verify는 중복 지급 없이 재생한다") + fun shouldPersistFactWhenPayverseWebhookCompletesFirst() { + val fixture = createFixture(PaymentGateway.PAYVERSE, PaymentStatus.REQUEST) + val request = webhook(fixture) + val before = storedState(fixture) + assertEquals(false, service.payverseWebhook(request.copy(sign = "invalid"))) + assertEquals(before, storedState(fixture)) + Mockito.doAnswer { invocation -> + assertReadCommitted() + invocation.callRealMethod() + }.`when`(chargeEventJobService).createAndProcessImmediate(fixture.chargeId, fixture.ownerId) + assertEquals(true, service.payverseWebhook(request)) + assertEquals(true, storedState(fixture).firstFact) + val next = addCharge(fixture, PaymentGateway.GOOGLE_IAP) + enableSuccessfulProvider(VerificationPath.GOOGLE, next) + assertEquals(false, verify(VerificationPath.GOOGLE, next.ownerId, next.chargeId).isFirstCharged) + val completed = storedState(fixture) + assertEquals(true, service.payverseWebhook(request)) + assertEquals(true, verify(VerificationPath.PAYVERSE, fixture.ownerId, fixture.chargeId).isFirstCharged) + assertEquals(completed, storedState(fixture)) + assertNoProviderOrEventCallsForReplay(fixture) + } + + @Test + @DisplayName("Payverse webhook 실패도 최초 fact와 잔액을 함께 rollback한다") + fun shouldRollBackWebhookFactWhenCompletionFails() { + val fixture = createFixture(PaymentGateway.PAYVERSE, PaymentStatus.REQUEST) + val before = storedState(fixture) + failAfterCompletion(fixture) + assertThrows(IllegalStateException::class.java) { service.payverseWebhook(webhook(fixture)) } + assertEquals(before, storedState(fixture)) + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("동일 회원 다른 charge의 동시 완료는 기존 이력이 없으면 하나만 true 있으면 둘 다 false다") + fun shouldSetExactlyOneFirstFactForConcurrentCharges(existingHistory: Boolean) { + val first = createFixture(PaymentGateway.GOOGLE_IAP, PaymentStatus.REQUEST) + val second = addCharge(first, PaymentGateway.PG) + if (existingHistory) addCharge(first, PaymentGateway.APPLE_IAP, PaymentStatus.COMPLETE) + enableSuccessfulProvider(VerificationPath.GOOGLE, first) + enableSuccessfulProvider(VerificationPath.HECTO, second) + val bothChargesLocked = CountDownLatch(2) + Mockito.doAnswer { invocation -> + awaitOtherLockedCharge(first, bothChargesLocked) + invocation.callRealMethod() + }.`when`(service).processGoogleIap(first.ownerId, first.chargeId, "test-product", "test-purchase") + Mockito.doAnswer { invocation -> + awaitOtherLockedCharge(second, bothChargesLocked) + invocation.callRealMethod() + }.`when`(service).verifyHecto(second.ownerId, VerifyRequest("test-receipt", second.chargeId.toString())) + val executor = Executors.newFixedThreadPool(2) + try { + val futures = listOf(first to VerificationPath.GOOGLE, second to VerificationPath.HECTO).map { (fixture, path) -> + executor.submit { + verify(path, fixture.ownerId, fixture.chargeId) + } + } + val responses = futures.map { it.get(30, TimeUnit.SECONDS) } + assertEquals(if (existingHistory) 0 else 1, responses.count { it.isFirstCharged }) + val states = listOf(storedState(first), storedState(second)) + assertEquals(if (existingHistory) 0 else 1, states.count { it.firstFact == true }) + assertEquals(if (existingHistory) 2 else 1, states.count { it.firstFact == false }) + assertTrue(states.all { it.paymentStatus == PaymentStatus.COMPLETE }) + assertEquals(listOf(200, 20, 300, 20, 300, 30), states.first().balances.first()) + } finally { + executor.shutdownNow() + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)) + } + } + + private fun awaitOtherLockedCharge(fixture: Fixture, bothChargesLocked: CountDownLatch) { + assertReadCommitted() + val locked = chargeRepository.findByIdForUpdate(fixture.chargeId)!! + assertEquals(PaymentStatus.REQUEST, locked.payment!!.status) + bothChargesLocked.countDown() + assertTrue(bothChargesLocked.await(10, TimeUnit.SECONDS)) + } + + @Test + @DisplayName("동일 charge 동시 재시도는 한 번만 지급하고 같은 최초 fact를 반환한다") + fun shouldCreditSameChargeOnlyOnceUnderConcurrentReplay() { + val fixture = createFixture(PaymentGateway.GOOGLE_IAP, PaymentStatus.REQUEST) + enableSuccessfulProvider(VerificationPath.GOOGLE, fixture) + val ready = CountDownLatch(2) + val executor = Executors.newFixedThreadPool(2) + try { + val futures = (1..2).map { + executor.submit { + ready.countDown() + assertTrue(ready.await(10, TimeUnit.SECONDS)) + verify(VerificationPath.GOOGLE, fixture.ownerId, fixture.chargeId) + } + } + assertTrue(futures.map { it.get(30, TimeUnit.SECONDS) }.all { it.isFirstCharged }) + assertEquals(true, storedState(fixture).firstFact) + assertEquals(listOf(100, 10, 300, 20, 300, 30), storedState(fixture).balances.first()) + Mockito.verify(googlePlayService, Mockito.times(1)).verifyAndConsumePurchase("test-purchase", "test-product") + } finally { + executor.shutdownNow() + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)) + } + } + + private fun failAfterCompletion(fixture: Fixture) { + Mockito.doAnswer { + assertReadCommitted() + val charge = chargeRepository.findById(fixture.chargeId).orElseThrow() + assertEquals(true, charge.isFirstCharged) + assertEquals(PaymentStatus.COMPLETE, charge.payment!!.status) + entityManager.flush() + throw IllegalStateException("test event failure after completion") + }.`when`(chargeEventJobService).createAndProcessImmediate(fixture.chargeId, fixture.ownerId) + } + + private fun assertReadCommitted() { + assertTrue(TransactionSynchronizationManager.isActualTransactionActive()) + assertEquals( + TransactionDefinition.ISOLATION_READ_COMMITTED, + TransactionSynchronizationManager.getCurrentTransactionIsolationLevel() + ) + } + + private fun assertNoProviderOrEventCallsForReplay(fixture: Fixture) { + Mockito.verifyNoInteractions(bootpay, okHttpClient) + Mockito.verify(chargeEventJobService, Mockito.times(1)).createAndProcessImmediate(fixture.chargeId, fixture.ownerId) + } + + private fun webhook(fixture: Fixture): PayverseWebhookRequest { + val sign = DigestUtils.sha512Hex("||t2-test-secret||t2-test-mid||${fixture.chargeId}||9.99||20261103||") + return PayverseWebhookRequest( + "PAYMENT", "t2-test-mid", "test-tid", "OTHER", "card", fixture.chargeId.toString(), + "USD", BigDecimal("9.99"), "SUCCESS", "20261103", sign + ) + } + + private fun enableSuccessfulProvider(path: VerificationPath, fixture: Fixture) { + when (path) { + VerificationPath.VERIFY, VerificationPath.HECTO -> { + Mockito.doAnswer { + assertReadCommitted() + hashMapOf("access_token" to "test-token") + }.`when`(bootpay).accessToken + Mockito.doReturn( + hashMapOf( + "receipt_id" to "test-receipt", + "method" to "card", + "pg" to "test-pg", + "status" to 1, + "price" to BigDecimal("9.9900") + ) + ).`when`(bootpay).getReceipt("test-receipt") + } + VerificationPath.GOOGLE -> Mockito.doAnswer { + assertReadCommitted() + "test-order" + }.`when`(googlePlayService) + .verifyAndConsumePurchase("test-purchase", "test-product") + VerificationPath.APPLE, VerificationPath.PAYVERSE -> { + val url = if (path == VerificationPath.APPLE) { + "https://apple.invalid/verifyReceipt" + } else { + "https://payverse.invalid/payment/search/transaction/test-transaction" + } + val body = if (path == VerificationPath.APPLE) { + """{"status":0}""" + } else { + val customerId = "${environment.getRequiredProperty("server.env")}_user_${fixture.ownerId}" + """{"resultStatus":"SUCCESS","tid":"test-tid","schemeGroup":"OTHER","schemeCode":"card", + "transactionType":"PAYMENT","transactionStatus":"SUCCESS","transactionMessage":"ok", + "orderId":"${fixture.chargeId}","customerId":"$customerId", + "requestCurrency":"USD","requestAmount":9.99}""" + } + val call = Mockito.mock(Call::class.java) + Mockito.doAnswer { + assertReadCommitted() + Response.Builder().request(Request.Builder().url(url).build()).protocol(Protocol.HTTP_1_1) + .code(200).message("OK").body(body.toResponseBody("application/json".toMediaType())).build() + }.`when`(call).execute() + Mockito.doAnswer { invocation -> + val request = invocation.getArgument(0) + assertEquals(url, request.url.toString()) + call + }.`when`(okHttpClient).newCall(Mockito.any(Request::class.java) ?: Request.Builder().url(url).build()) + } + } + } + + private fun addCharge(fixture: Fixture, gateway: PaymentGateway, status: PaymentStatus = PaymentStatus.REQUEST): Fixture { + return transactionTemplate.execute { + val original = chargeRepository.findById(fixture.chargeId).orElseThrow() + val charge = Charge(100, 10).apply { + title = "100 + 10" + can = original.can + member = original.member + payment = Payment(status, gateway).apply { + price = BigDecimal("9.99") + locale = "USD" + } + } + chargeRepository.saveAndFlush(charge) + entityManager.clear() + fixture.copy(chargeId = charge.id!!) + }!! + } + + private fun verify(path: VerificationPath, memberId: Long, chargeId: Long): ChargeCompleteResponse { + return when (path) { + VerificationPath.VERIFY -> service.verify(memberId, VerifyRequest("test-receipt", chargeId.toString())) + VerificationPath.HECTO -> service.verifyHecto(memberId, VerifyRequest("test-receipt", chargeId.toString())) + VerificationPath.PAYVERSE -> service.payverseVerify( + memberId, + PayverseVerifyRequest("test-transaction", chargeId.toString()) + ) + VerificationPath.APPLE -> service.appleVerify(memberId, AppleVerifyRequest("test-receipt", chargeId)) + VerificationPath.GOOGLE -> service.processGoogleIap(memberId, chargeId, "test-product", "test-purchase") + } + } + + private fun createFixture(gateway: PaymentGateway, status: PaymentStatus): Fixture { + return transactionTemplate.execute { + val owner = Member(password = "password", nickname = "charge-owner").apply { + pgChargeCan = 100 + pgRewardCan = 10 + googleChargeCan = 200 + googleRewardCan = 20 + appleChargeCan = 300 + appleRewardCan = 30 + } + val requester = Member(password = "password", nickname = "charge-requester").apply { + pgChargeCan = 400 + pgRewardCan = 40 + googleChargeCan = 500 + googleRewardCan = 50 + appleChargeCan = 600 + appleRewardCan = 60 + } + entityManager.persist(owner) + entityManager.persist(requester) + val product = Can("100 + 10", 100, 10, BigDecimal("9.99"), "USD", CanStatus.SALE) + entityManager.persist(product) + val charge = Charge(100, 10).apply { + can = product + title = "100 + 10" + member = owner + payment = Payment(status = status, paymentGateway = gateway).apply { + price = BigDecimal("9.99") + locale = "USD" + receiptId = "stored-receipt" + method = "stored-method" + orderId = "stored-order" + } + } + chargeRepository.saveAndFlush(charge) + val fixture = Fixture(owner.id!!, requester.id!!, charge.id!!) + entityManager.clear() + fixture + }!! + } + + private fun storedState(fixture: Fixture): StoredState { + return transactionTemplate.execute { + entityManager.flush() + entityManager.clear() + val charge = chargeRepository.findById(fixture.chargeId).orElseThrow() + val payment = charge.payment!! + val balances = listOf(fixture.ownerId, fixture.requesterId).map { memberId -> + val member = memberRepository.findById(memberId).orElseThrow() + listOf( + member.pgChargeCan, + member.pgRewardCan, + member.googleChargeCan, + member.googleRewardCan, + member.appleChargeCan, + member.appleRewardCan + ) + } + StoredState( + charge.member!!.id!!, charge.status, charge.chargeCan, charge.rewardCan, + payment.status, payment.receiptId, payment.method, payment.orderId, + balances, chargeRepository.count(), jobRepository.count(), charge.isFirstCharged + ) + }!! + } + + private fun assertNoProviderOrEventCalls() { + Mockito.verifyNoInteractions(bootpay, okHttpClient, googlePlayService) + Mockito.verify(service, Mockito.never()).createBootpay(Mockito.anyString(), Mockito.anyString()) + Mockito.verify(chargeEventJobService, Mockito.never()) + .createAndProcessImmediate(Mockito.anyLong(), Mockito.anyLong()) + } + + enum class VerificationPath(val gateway: PaymentGateway) { + VERIFY(PaymentGateway.PG), + HECTO(PaymentGateway.PG), + PAYVERSE(PaymentGateway.PAYVERSE), + APPLE(PaymentGateway.APPLE_IAP), + GOOGLE(PaymentGateway.GOOGLE_IAP) + } + + private data class Fixture(val ownerId: Long, val requesterId: Long, val chargeId: Long) + + private data class StoredState( + val ownerId: Long, + val chargeStatus: ChargeStatus, + val chargeCan: Int, + val rewardCan: Int, + val paymentStatus: PaymentStatus, + val receiptId: String?, + val method: String?, + val orderId: String?, + val balances: List>, + val chargeCount: Long, + val eventJobCount: Long, + val firstFact: Boolean? + ) + + companion object { + @JvmStatic + fun paidHistories(): List { + return listOf(PaymentGateway.PG, PaymentGateway.PAYVERSE, PaymentGateway.APPLE_IAP, PaymentGateway.GOOGLE_IAP) + .flatMap { gateway -> listOf(Arguments.of(gateway, false), Arguments.of(gateway, true)) } + } + + @JvmStatic + fun refundedReverifications(): List { + return listOf(VerificationPath.VERIFY, VerificationPath.HECTO, VerificationPath.APPLE) + .flatMap { path -> listOf(null, true, false).map { Arguments.of(path, it) } } + } + } +}