Test #466
@@ -50,7 +50,7 @@ class ChargeController(
|
|||||||
|
|
||||||
val response = service.payverseVerify(memberId = member.id!!, verifyRequest)
|
val response = service.payverseVerify(memberId = member.id!!, verifyRequest)
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
ApiResponse.ok(Unit)
|
ApiResponse.ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payverse Webhook 엔드포인트 (payverseVerify 아래)
|
// Payverse Webhook 엔드포인트 (payverseVerify 아래)
|
||||||
@@ -100,7 +100,7 @@ class ChargeController(
|
|||||||
|
|
||||||
val response = service.verify(memberId = member.id!!, verifyRequest)
|
val response = service.verify(memberId = member.id!!, verifyRequest)
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
ApiResponse.ok(Unit)
|
ApiResponse.ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/verify/hecto")
|
@PostMapping("/verify/hecto")
|
||||||
@@ -114,7 +114,7 @@ class ChargeController(
|
|||||||
|
|
||||||
val response = service.verifyHecto(memberId = member.id!!, verifyRequest)
|
val response = service.verifyHecto(memberId = member.id!!, verifyRequest)
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
ApiResponse.ok(Unit)
|
ApiResponse.ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/apple")
|
@PostMapping("/apple")
|
||||||
@@ -140,7 +140,7 @@ class ChargeController(
|
|||||||
|
|
||||||
val response = service.appleVerify(memberId = member.id!!, verifyRequest)
|
val response = service.appleVerify(memberId = member.id!!, verifyRequest)
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
ApiResponse.ok(Unit)
|
ApiResponse.ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/google")
|
@PostMapping("/google")
|
||||||
@@ -172,7 +172,7 @@ class ChargeController(
|
|||||||
)
|
)
|
||||||
|
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
ApiResponse.ok(Unit)
|
ApiResponse.ok(response)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
|
|||||||
+549
@@ -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<Long>()
|
||||||
|
|
||||||
|
@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<String> {
|
||||||
|
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<String>): 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<Int>) {
|
||||||
|
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<List<Int>>,
|
||||||
|
val chargeCount: Long,
|
||||||
|
val eventJobCount: Long
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user