From f250cbda84d54303ea3f8feaa1facd7a19c05509 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 3 Nov 2026 07:09:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(charge):=20=EA=B2=B0=EC=A0=9C=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=9D=91=EB=8B=B5=EC=9D=84=20=EB=8B=A4=EC=84=AF=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20API=EC=97=90=20=EB=B0=98=ED=99=98=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/can/charge/ChargeController.kt | 10 +- .../charge/ChargeControllerIntegrationTest.kt | 549 ++++++++++++++++++ 2 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeControllerIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt index 042da59f..705feac5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt @@ -50,7 +50,7 @@ class ChargeController( val response = service.payverseVerify(memberId = member.id!!, verifyRequest) trackingCharge(member, response) - ApiResponse.ok(Unit) + ApiResponse.ok(response) } // Payverse Webhook 엔드포인트 (payverseVerify 아래) @@ -100,7 +100,7 @@ class ChargeController( val response = service.verify(memberId = member.id!!, verifyRequest) trackingCharge(member, response) - ApiResponse.ok(Unit) + ApiResponse.ok(response) } @PostMapping("/verify/hecto") @@ -114,7 +114,7 @@ class ChargeController( val response = service.verifyHecto(memberId = member.id!!, verifyRequest) trackingCharge(member, response) - ApiResponse.ok(Unit) + ApiResponse.ok(response) } @PostMapping("/apple") @@ -140,7 +140,7 @@ class ChargeController( val response = service.appleVerify(memberId = member.id!!, verifyRequest) trackingCharge(member, response) - ApiResponse.ok(Unit) + ApiResponse.ok(response) } @PostMapping("/google") @@ -172,7 +172,7 @@ class ChargeController( ) trackingCharge(member, response) - ApiResponse.ok(Unit) + ApiResponse.ok(response) } else { throw SodaException(messageKey = "can.charge.invalid_payment_info") } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeControllerIntegrationTest.kt new file mode 100644 index 00000000..c674ad09 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeControllerIntegrationTest.kt @@ -0,0 +1,549 @@ +package kr.co.vividnext.sodalive.can.charge + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.bootpay.Bootpay +import kr.co.vividnext.sodalive.can.Can +import kr.co.vividnext.sodalive.can.CanRepository +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.google.GooglePlayService +import kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorker +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType +import kr.co.vividnext.sodalive.marketing.AdTrackingService +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +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.ChargeEventJobWorker +import okhttp3.OkHttpClient +import okhttp3.Request +import org.apache.commons.codec.digest.DigestUtils +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +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.EnumSource +import org.mockito.ArgumentCaptor +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.boot.test.web.client.TestRestTemplate +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.support.TransactionTemplate +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.UUID +import javax.persistence.EntityManager + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:charge-controller-http;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "jwt.secret=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZg==", + "apple.iap-verify-url=https://apple.invalid/verifyReceipt", + "apple.iap-verify-sandbox-url=https://apple-sandbox.invalid/verifyReceipt", + "payverse.host=https://payverse.invalid", + "payverse.inbound-ip=192.0.2.40", + "payverse.usd-mid=http-usd-mid", + "payverse.usd-client-key=http-usd-client", + "payverse.usd-secret-key=http-usd-secret" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class ChargeControllerIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate, + private val objectMapper: ObjectMapper, + private val chargeRepository: ChargeRepository, + private val canRepository: CanRepository, + private val memberRepository: MemberRepository, + private val tokenRepository: MemberTokenRepository, + private val tokenProvider: TokenProvider, + private val jobRepository: ChargeEventJobRepository, + private val transactionTemplate: TransactionTemplate, + private val entityManager: EntityManager +) { + @SpyBean + private lateinit var service: ChargeService + + @MockBean + private lateinit var okHttpClient: OkHttpClient + + @MockBean + private lateinit var googlePlayService: GooglePlayService + + @MockBean + private lateinit var trackingService: AdTrackingService + + @MockBean + private lateinit var chargeEventJobWorker: ChargeEventJobWorker + + @MockBean + private lateinit var translationJobWorker: TranslationJobWorker + + private lateinit var bootpay: Bootpay + private val memberIds = mutableListOf() + + @BeforeEach + fun setUp() { + val blockedRequest = Request.Builder().url("https://network-blocked.invalid").build() + Mockito.doThrow(AssertionError("External HTTP is blocked")) + .`when`(okHttpClient).newCall(Mockito.any(Request::class.java) ?: blockedRequest) + Mockito.doThrow(AssertionError("Real Google verification is blocked")) + .`when`(googlePlayService).verifyAndConsumePurchase(Mockito.anyString(), Mockito.anyString()) + bootpay = Mockito.mock(Bootpay::class.java) + Mockito.doThrow(AssertionError("Real Bootpay token requests are blocked")).`when`(bootpay).accessToken + Mockito.doThrow(AssertionError("Real Bootpay receipt requests are blocked")) + .`when`(bootpay).getReceipt(Mockito.anyString()) + Mockito.doReturn(bootpay).`when`(service).createBootpay(Mockito.anyString(), Mockito.anyString()) + } + + @AfterEach + fun cleanUp() { + tokenRepository.deleteAllById(memberIds) + jobRepository.deleteAllInBatch() + chargeRepository.deleteAll() + canRepository.deleteAllInBatch() + memberRepository.deleteAllById(memberIds) + memberIds.clear() + } + + @ParameterizedTest + @EnumSource(VerificationPath::class) + @DisplayName("다섯 완료 HTTP 경로는 실제 서비스 결과의 네 필드와 기존 envelope를 반환한다") + fun shouldReturnChargeCompleteDataForAllFiveVerificationEndpoints(path: VerificationPath) { + val owner = createMember(container = if (path.isIap) "web" else "ios") + val chargeId = if (path == VerificationPath.GOOGLE) { + allowGooglePurchase() + null + } else { + createCharge(owner, path.gateway, first = true) + } + val before = chargeId?.let { storedState(it, owner.id!!) } + + val data = successData(post(path.path, verificationRequest(path, chargeId ?: 0), token(owner))) + + assertCompletion(data, first = true, iap = path.isIap) + if (chargeId == null) { + val createdId = transactionTemplate.execute { chargeRepository.findAll().single().id!! }!! + assertCompleted(createdId, owner.id!!, PaymentGateway.GOOGLE_IAP, listOf(0, 0, 100, 0, 0, 0)) + Mockito.verify(googlePlayService).verifyAndConsumePurchase("http-purchase", "http-product") + Mockito.verifyNoInteractions(bootpay, okHttpClient) + } else { + assertEquals(before, storedState(chargeId, owner.id!!)) + assertNoProviderCalls() + } + assertTracking(owner.id!!, AdTrackingHistoryType.FIRST_PAYMENT) + Mockito.verifyNoMoreInteractions(googlePlayService, trackingService) + } + + @ParameterizedTest + @EnumSource(value = VerificationPath::class, names = ["GOOGLE"], mode = EnumSource.Mode.EXCLUDE) + @DisplayName("저장된 true·false·null과 금액·통화는 재호출 및 단말 종류와 무관하게 직렬화된다") + fun shouldSerializeCompletionFlagsAndPreservePriceAndCurrency(path: VerificationPath) { + listOf(true to true, false to false, null to false).forEach { (first, expected) -> + val owner = createMember(container = if (path.isIap) "web" else "aos") + val chargeId = createCharge(owner, path.gateway, first = first) + val before = storedState(chargeId, owner.id!!) + val bearer = token(owner) + + repeat(2) { + assertCompletion( + successData(post(path.path, verificationRequest(path, chargeId), bearer)), + first = expected, + iap = path.isIap + ) + } + + assertEquals(before, storedState(chargeId, owner.id!!)) + val type = if (first == true) AdTrackingHistoryType.FIRST_PAYMENT else AdTrackingHistoryType.REPEAT_PAYMENT + assertTracking(owner.id!!, type, count = 2) + } + assertNoProviderCalls() + Mockito.verifyNoMoreInteractions(trackingService) + } + + @Test + @DisplayName("Google 신규 REQUEST는 기존 유료 이력이 있으면 false를 저장하고 REPEAT_PAYMENT를 전달한다") + fun shouldCompleteNewGoogleChargeAsRepeatAfterLegacyPaidHistory() { + val owner = createMember(container = "ios") + val legacyId = createCharge(owner, PaymentGateway.GOOGLE_IAP, first = null) + val legacyBefore = storedState(legacyId, owner.id!!) + allowGooglePurchase() + + assertCompletion(successData(post("/charge/google", googleRequest(), token(owner))), first = false, iap = true) + + transactionTemplate.executeWithoutResult { + val created = chargeRepository.findAll().single { it.id != legacyId } + assertEquals(false, created.isFirstCharged) + assertEquals(PaymentStatus.COMPLETE, created.payment!!.status) + assertEquals("http-google-order", created.payment!!.orderId) + } + assertEquals( + legacyBefore.copy(balances = listOf(listOf(0, 0, 100, 0, 0, 0)), chargeCount = 2), + storedState(legacyId, owner.id!!) + ) + assertTracking(owner.id!!, AdTrackingHistoryType.REPEAT_PAYMENT) + Mockito.verify(googlePlayService).verifyAndConsumePurchase("http-purchase", "http-product") + Mockito.verifyNoMoreInteractions(googlePlayService, trackingService) + Mockito.verifyNoInteractions(bootpay, okHttpClient) + } + + @ParameterizedTest + @EnumSource(value = VerificationPath::class, names = ["GOOGLE"], mode = EnumSource.Mode.EXCLUDE) + @DisplayName("타인 소유 REQUEST·COMPLETE HTTP 요청은 동일한 기존 오류와 무변경 상태를 유지한다") + fun shouldRejectForeignChargesBeforeShortcutOrMutation(path: VerificationPath) { + listOf(PaymentStatus.REQUEST, PaymentStatus.COMPLETE).forEach { status -> + val owner = createMember() + val requester = createMember() + val chargeId = createCharge( + owner, + path.gateway, + status, + first = if (status == PaymentStatus.COMPLETE) true else null + ) + val before = storedState(chargeId, owner.id!!, requester.id!!) + + val response = post(path.path, verificationRequest(path, chargeId), token(requester)) + + assertEquals(HttpStatus.OK, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEnvelope(body) + assertEquals(false, body["success"].booleanValue()) + assertEquals("There is an error with the payment information.", body["message"].textValue()) + assertEquals(true, body["data"].isNull) + assertEquals(true, body["errorProperty"].isNull) + assertEquals(before, storedState(chargeId, owner.id!!, requester.id!!)) + } + assertNoProviderCalls() + Mockito.verifyNoInteractions(trackingService) + } + + @ParameterizedTest + @EnumSource(VerificationPath::class) + @DisplayName("다섯 완료 HTTP 경로는 익명 요청을 401로 거부하고 charge를 생성하지 않는다") + fun shouldRejectAnonymousVerificationBeforeCreation(path: VerificationPath) { + val before = chargeRepository.count() + assertEquals(HttpStatus.UNAUTHORIZED, post(path.path, verificationRequest(path, 0)).statusCode) + assertEquals(before, chargeRepository.count()) + assertNoProviderCalls() + Mockito.verifyNoInteractions(trackingService) + } + + @Test + @DisplayName("partner PID와 미래 만료시각이 모두 유효한 회원에게만 기존 tracking이 전달된다") + fun shouldKeepPartnerTrackingEligibility() { + val future = LocalDateTime.now().plusDays(1) + listOf( + null to future, + "" to future, + " " to future, + "http-partner" to null, + "http-partner" to LocalDateTime.now().minusDays(1) + ).forEach { (pid, expiration) -> + val owner = createMember(pid = pid, expiration = expiration) + val chargeId = createCharge(owner, PaymentGateway.PG, first = true) + assertCompletion( + successData(post("/charge/verify", VerifyRequest("http-receipt", "$chargeId"), token(owner))), + first = true, + iap = false + ) + } + assertNoProviderCalls() + Mockito.verifyNoInteractions(trackingService) + } + + @Test + @DisplayName("PG·Apple·Payverse 생성 HTTP 응답은 chargeId 및 기존 payloadJson shape만 반환한다") + fun shouldKeepCreationResponseShapes() { + val owner = createMember() + val bearer = token(owner) + val can = canRepository.saveAndFlush(Can("HTTP pack", 100, 10, BigDecimal("9.99"), "USD", CanStatus.SALE)) + val requests = listOf( + Triple("/charge", ChargeRequest(can.id!!, PaymentGateway.PG), PaymentGateway.PG), + Triple( + "/charge/apple", + AppleChargeRequest("HTTP pack", 100, PaymentGateway.APPLE_IAP, BigDecimal("9.99"), "USD"), + PaymentGateway.APPLE_IAP + ), + Triple("/charge/payverse", PayverseChargeRequest(can.id!!), PaymentGateway.PAYVERSE) + ) + requests.forEach { (path, request, gateway) -> + val data = successData(post(path, request, bearer)) + val keys = if (path == "/charge/payverse") setOf("chargeId", "payloadJson") else setOf("chargeId") + assertEquals(keys, data.fieldNames().asSequence().toSet()) + assertEquals(true, data["chargeId"].isIntegralNumber) + transactionTemplate.executeWithoutResult { + val charge = chargeRepository.findById(data["chargeId"].longValue()).orElseThrow() + assertEquals(owner.id, charge.member!!.id) + assertEquals(PaymentStatus.REQUEST, charge.payment!!.status) + assertEquals(gateway, charge.payment!!.paymentGateway) + assertEquals(null, charge.isFirstCharged) + } + if (path == "/charge/payverse") { + assertEquals(true, data["payloadJson"].isTextual) + val payload = objectMapper.readTree(data["payloadJson"].textValue()) + assertEquals( + setOf( + "mid", "clientKey", "orderId", "customerId", "productName", "requestCurrency", + "requestAmount", "reqDate", "sign" + ), + payload.fieldNames().asSequence().toSet() + ) + assertEquals(data["chargeId"].asText(), payload["orderId"].textValue()) + assertEquals("USD", payload["requestCurrency"].textValue()) + assertEquals(0, BigDecimal("9.99").compareTo(payload["requestAmount"].decimalValue())) + } + } + assertNoProviderCalls() + Mockito.verifyNoInteractions(trackingService) + } + + @Test + @DisplayName("Payverse signed webhook은 기존 단일 key 응답과 IP·서명 검사 및 완료 replay를 유지한다") + fun shouldKeepSignedWebhookResponseShapeAndRejectInvalidIpOrSignature() { + val owner = createMember() + val chargeId = createCharge(owner, PaymentGateway.PAYVERSE, PaymentStatus.REQUEST) + val request = PayverseWebhookRequest( + "PAYMENT", "http-usd-mid", "http-tid", "PVKR", "041", "$chargeId", + "USD", BigDecimal("9.99"), "SUCCESS", "20261103120000", + DigestUtils.sha512Hex("||http-usd-secret||http-usd-mid||$chargeId||9.99||20261103120000||") + ) + val before = storedState(chargeId, owner.id!!) + assertEquals(HttpStatus.NOT_FOUND, post("/charge/payverse/webhook", request, ip = "192.0.2.41").statusCode) + assertEquals(before, storedState(chargeId, owner.id!!)) + assertEquals( + HttpStatus.NOT_FOUND, + post("/charge/payverse/webhook", request.copy(sign = "invalid"), ip = "192.0.2.40").statusCode + ) + assertEquals(before, storedState(chargeId, owner.id!!)) + + repeat(2) { + val response = post("/charge/payverse/webhook", request, ip = "192.0.2.40") + assertEquals(HttpStatus.OK, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEquals(setOf("receiveResult"), body.fieldNames().asSequence().toSet()) + assertEquals("SUCCESS", body["receiveResult"].textValue()) + assertCompleted(chargeId, owner.id!!, PaymentGateway.PAYVERSE, listOf(100, 10, 0, 0, 0, 0)) + } + assertEquals(1L, chargeRepository.count()) + assertNoProviderCalls() + Mockito.verifyNoInteractions(trackingService) + val completed = storedState(chargeId, owner.id!!) + assertCompletion( + successData(post("/charge/payverse/verify", PayverseVerifyRequest("http-tid", "$chargeId"), token(owner))), + first = true, + iap = false + ) + assertEquals(completed, storedState(chargeId, owner.id!!)) + assertTracking(owner.id!!, AdTrackingHistoryType.FIRST_PAYMENT) + assertNoProviderCalls() + Mockito.verifyNoMoreInteractions(trackingService) + } + + private fun createMember( + pid: String? = "http-partner", + expiration: LocalDateTime? = LocalDateTime.now().plusDays(1), + container: String = "web" + ): Member { + return memberRepository.saveAndFlush( + Member( + email = "http-${UUID.randomUUID()}@test.invalid", + password = "test-password", + nickname = "http-owner", + activePid = pid, + partnerExpirationDatetime = expiration, + container = container + ) + ).also { memberIds.add(it.id!!) } + } + + private fun createCharge( + owner: Member, + gateway: PaymentGateway, + status: PaymentStatus = PaymentStatus.COMPLETE, + first: Boolean? = null + ): Long = transactionTemplate.execute { + val charge = Charge(100, 10).apply { + member = entityManager.getReference(Member::class.java, owner.id!!) + isFirstCharged = first + 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).id!! + }!! + + private fun token(member: Member): String = tokenProvider.createToken( + UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_USER"))), + member.id!! + ) + + private fun googleRequest() = GoogleChargeRequest( + "HTTP pack", + 100, + BigDecimal("9.99"), + "USD", + "http-product", + "http-purchase", + PaymentGateway.GOOGLE_IAP + ) + + private fun verificationRequest(path: VerificationPath, chargeId: Long): Any = when (path) { + VerificationPath.VERIFY, VerificationPath.HECTO -> VerifyRequest("http-receipt", "$chargeId") + VerificationPath.PAYVERSE -> PayverseVerifyRequest("http-transaction", "$chargeId") + VerificationPath.APPLE -> AppleVerifyRequest("http-receipt", chargeId) + VerificationPath.GOOGLE -> googleRequest() + } + + private fun allowGooglePurchase() { + Mockito.doReturn("http-google-order").`when`(googlePlayService) + .verifyAndConsumePurchase("http-purchase", "http-product") + } + + private fun post(path: String, request: Any, bearer: String? = null, ip: String? = null): ResponseEntity { + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_JSON + set(HttpHeaders.ACCEPT_LANGUAGE, "en") + bearer?.let { setBearerAuth(it) } + ip?.let { set("X-Forwarded-For", it) } + } + val response = restTemplate.exchange(path, HttpMethod.POST, HttpEntity(request, headers), String::class.java) + println("HTTP EVIDENCE POST $path ${response.statusCodeValue}: ${response.body}") + return response + } + + private fun assertEnvelope(body: JsonNode) { + assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet()) + assertEquals(true, body["success"].isBoolean) + } + + private fun successData(response: ResponseEntity): JsonNode { + assertEquals(HttpStatus.OK, response.statusCode, response.body) + val body = objectMapper.readTree(response.body) + assertEnvelope(body) + assertEquals(true, body["success"].booleanValue(), response.body) + assertEquals(true, body["message"].isNull) + assertEquals(true, body["errorProperty"].isNull) + assertEquals(true, body["data"].isObject, response.body) + return body["data"] + } + + private fun assertCompletion(data: JsonNode, first: Boolean, iap: Boolean) { + assertEquals(setOf("price", "currencyCode", "isFirstCharged", "isIap"), data.fieldNames().asSequence().toSet()) + assertEquals(true, data["price"].isNumber) + assertEquals(0, BigDecimal("9.99").compareTo(data["price"].decimalValue())) + assertEquals("USD", data["currencyCode"].textValue()) + listOf("isFirstCharged" to first, "isIap" to iap).forEach { (key, expected) -> + assertEquals(true, data[key].isBoolean, data.toString()) + assertEquals(expected, data[key].booleanValue()) + } + } + + private fun assertTracking(memberId: Long, type: AdTrackingHistoryType, count: Int = 1) { + val prices = ArgumentCaptor.forClass(BigDecimal::class.java) + Mockito.verify(trackingService, Mockito.times(count)).saveTrackingHistory( + Mockito.eq("http-partner") ?: "", + Mockito.eq(type) ?: type, + Mockito.eq(memberId), + prices.capture(), + Mockito.eq("USD") + ) + prices.allValues.forEach { assertEquals(0, BigDecimal("9.99").compareTo(it)) } + } + + private fun assertNoProviderCalls() { + Mockito.verifyNoInteractions(bootpay, okHttpClient, googlePlayService) + Mockito.verify(service, Mockito.never()).createBootpay(Mockito.anyString(), Mockito.anyString()) + } + + private fun assertCompleted(chargeId: Long, ownerId: Long, gateway: PaymentGateway, expectedBalances: List) { + transactionTemplate.executeWithoutResult { + val charge = chargeRepository.findById(chargeId).orElseThrow() + assertEquals(ownerId, charge.member!!.id) + assertEquals(true, charge.isFirstCharged) + assertEquals(PaymentStatus.COMPLETE, charge.payment!!.status) + assertEquals(gateway, charge.payment!!.paymentGateway) + assertEquals(expectedBalances, balances(memberRepository.findById(ownerId).orElseThrow())) + } + } + + private fun storedState(chargeId: Long, vararg memberIds: Long): StoredState = transactionTemplate.execute { + val charge = chargeRepository.findById(chargeId).orElseThrow() + val payment = charge.payment!! + StoredState( + charge.status, + charge.isFirstCharged, + charge.chargeCan, + charge.rewardCan, + payment.status, + payment.paymentGateway, + payment.price, + payment.locale, + payment.receiptId, + payment.method, + payment.orderId, + memberIds.map { balances(memberRepository.findById(it).orElseThrow()) }, + chargeRepository.count(), + jobRepository.count() + ) + }!! + + private fun balances(member: Member) = listOf( + member.pgChargeCan, + member.pgRewardCan, + member.googleChargeCan, + member.googleRewardCan, + member.appleChargeCan, + member.appleRewardCan + ) + + enum class VerificationPath(val path: String, val gateway: PaymentGateway, val isIap: Boolean) { + VERIFY("/charge/verify", PaymentGateway.PG, false), + HECTO("/charge/verify/hecto", PaymentGateway.PG, false), + PAYVERSE("/charge/payverse/verify", PaymentGateway.PAYVERSE, false), + APPLE("/charge/apple/verify", PaymentGateway.APPLE_IAP, true), + GOOGLE("/charge/google", PaymentGateway.GOOGLE_IAP, true) + } + + private data class StoredState( + val chargeStatus: ChargeStatus, + val first: Boolean?, + val chargeCan: Int, + val rewardCan: Int, + val paymentStatus: PaymentStatus, + val gateway: PaymentGateway, + val price: BigDecimal, + val locale: String?, + val receiptId: String?, + val method: String?, + val orderId: String?, + val balances: List>, + val chargeCount: Long, + val eventJobCount: Long + ) +}