feat(charge): 결제 완료 응답을 다섯 검증 API에 반환한다

This commit is contained in:
2026-11-03 07:09:21 +09:00
parent 4c0fc4298b
commit f250cbda84
2 changed files with 554 additions and 5 deletions
@@ -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
)
}