fix(charge): 충전 이벤트 보너스 지급을 안정화한다

This commit is contained in:
2026-05-18 13:34:12 +09:00
parent acd0393a0e
commit 810b143c9e
15 changed files with 929 additions and 102 deletions

View File

@@ -0,0 +1,73 @@
package kr.co.vividnext.sodalive.admin.event.charge
import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJob
import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobRepository
import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus
import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobType
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class AdminChargeEventJobServiceTest {
@Test
fun shouldReturnOnlyPendingAndFailedJobs() {
val repository = Mockito.mock(ChargeEventJobRepository::class.java)
val service = AdminChargeEventJobService(repository)
val pending = job(1L, ChargeEventJobStatus.PENDING)
val failed = job(2L, ChargeEventJobStatus.FAILED)
Mockito.`when`(repository.findVisibleAdminJobs()).thenReturn(listOf(pending, failed))
val responses = service.getJobs()
assertEquals(listOf(1L, 2L), responses.map { it.id })
assertEquals(listOf(ChargeEventJobStatus.PENDING, ChargeEventJobStatus.FAILED), responses.map { it.status })
}
@Test
fun shouldRetryOnlyFailedJob() {
val repository = Mockito.mock(ChargeEventJobRepository::class.java)
val service = AdminChargeEventJobService(repository)
val failed = job(1L, ChargeEventJobStatus.FAILED)
Mockito.`when`(repository.findByIdForUpdate(1L)).thenReturn(failed)
service.retry(1L)
assertEquals(ChargeEventJobStatus.PENDING, failed.status)
assertEquals(0, failed.retryCount)
}
@Test
fun shouldNotChangeNonFailedJobOnRetryRequest() {
val repository = Mockito.mock(ChargeEventJobRepository::class.java)
val service = AdminChargeEventJobService(repository)
val pending = job(1L, ChargeEventJobStatus.PENDING)
Mockito.`when`(repository.findByIdForUpdate(1L)).thenReturn(pending)
service.retry(1L)
assertEquals(ChargeEventJobStatus.PENDING, pending.status)
assertEquals(2, pending.retryCount)
}
private fun job(id: Long, status: ChargeEventJobStatus): ChargeEventJob {
return ChargeEventJob(
sourceChargeId = 100L,
memberId = 10L,
chargeEventId = 20L,
jobType = ChargeEventJobType.ACTIVE_CHARGE_EVENT,
idempotencyKey = "charge-event:100:ACTIVE_CHARGE_EVENT:20",
additionalCan = 10,
paymentGateway = PaymentGateway.PG,
container = "pg",
methodSnapshot = "봄 이벤트",
status = status,
retryCount = 2,
nextRetryAt = LocalDateTime.now()
).also { it.id = id }
}
}

View File

@@ -0,0 +1,89 @@
package kr.co.vividnext.sodalive.can.charge.event
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class ChargeEventJobServiceTest {
@Test
fun shouldPayBonusOnceFromJobSnapshot() {
val jobRepository = Mockito.mock(ChargeEventJobRepository::class.java)
val chargeRepository = Mockito.mock(ChargeRepository::class.java)
val memberRepository = Mockito.mock(MemberRepository::class.java)
val service = ChargeEventJobService(jobRepository, chargeRepository, memberRepository)
val member = member(id = 10L)
val job = job(id = 1L, memberId = 10L, additionalCan = 15, paymentGateway = PaymentGateway.PAYVERSE)
Mockito.`when`(jobRepository.findByIdForUpdate(1L)).thenReturn(job)
Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(member)
Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as Charge).also { it.id = 200L }
}
service.processJob(1L)
assertEquals(ChargeEventJobStatus.DONE, job.status)
assertEquals(15, member.pgRewardCan)
assertEquals(200L, job.resultChargeId)
assertNotNull(job.processedAt)
Mockito.verify(chargeRepository).save(
Mockito.argThat { charge ->
charge.status == ChargeStatus.EVENT &&
charge.rewardCan == 15 &&
charge.member == member &&
charge.payment?.paymentGateway == PaymentGateway.PAYVERSE &&
charge.payment?.method == "봄 이벤트"
}
)
}
@Test
fun shouldSkipAlreadyDoneJobWithoutPayingAgain() {
val jobRepository = Mockito.mock(ChargeEventJobRepository::class.java)
val chargeRepository = Mockito.mock(ChargeRepository::class.java)
val memberRepository = Mockito.mock(MemberRepository::class.java)
val service = ChargeEventJobService(jobRepository, chargeRepository, memberRepository)
val job = job(id = 1L, status = ChargeEventJobStatus.DONE)
Mockito.`when`(jobRepository.findByIdForUpdate(1L)).thenReturn(job)
service.processJob(1L)
Mockito.verify(chargeRepository, Mockito.never()).save(Mockito.any(Charge::class.java))
Mockito.verify(memberRepository, Mockito.never()).findByIdForUpdate(Mockito.anyLong())
}
private fun job(
id: Long,
memberId: Long = 10L,
additionalCan: Int = 10,
paymentGateway: PaymentGateway = PaymentGateway.PG,
status: ChargeEventJobStatus = ChargeEventJobStatus.PROCESSING
): ChargeEventJob {
return ChargeEventJob(
sourceChargeId = 100L,
memberId = memberId,
chargeEventId = 20L,
jobType = ChargeEventJobType.ACTIVE_CHARGE_EVENT,
idempotencyKey = "charge-event:100:ACTIVE_CHARGE_EVENT:20",
additionalCan = additionalCan,
paymentGateway = paymentGateway,
container = "pg",
methodSnapshot = "봄 이벤트",
status = status,
nextRetryAt = LocalDateTime.now()
).also { it.id = id }
}
private fun member(id: Long): Member {
return Member(password = "pw", nickname = "tester").also { it.id = id }
}
}

View File

@@ -0,0 +1,122 @@
package kr.co.vividnext.sodalive.can.charge.event
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.transaction.support.AbstractPlatformTransactionManager
import org.springframework.transaction.support.DefaultTransactionStatus
import java.time.LocalDateTime
import java.util.Optional
class ChargeEventJobWorkerTest {
@Test
fun shouldRunEveryFiveMinutesByDefault() {
val scheduled = ChargeEventJobWorker::class.java
.getDeclaredMethod("runPendingJobs")
.getAnnotation(Scheduled::class.java)
assertEquals("\${sodalive.charge-event-job.fixed-delay-ms:300000}", scheduled.fixedDelayString)
}
@Test
fun shouldClaimAtMostThirtyPendingJobs() {
val repository = Mockito.mock(ChargeEventJobRepository::class.java)
val service = Mockito.mock(ChargeEventJobService::class.java)
val worker = ChargeEventJobWorker(repository, service, ChargeEventJobTestTransactionManager())
val jobIds = (1L..30L).toList()
Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenReturn(jobIds)
jobIds.forEach { jobId ->
Mockito.`when`(repository.findById(jobId)).thenReturn(Optional.of(job(jobId)))
}
worker.runPendingJobs()
Mockito.verify(repository).findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))
Mockito.verify(service, Mockito.times(30)).processJob(Mockito.anyLong())
}
@Test
fun shouldReturnWhenNoPendingJobsExist() {
val repository = Mockito.mock(ChargeEventJobRepository::class.java)
val service = Mockito.mock(ChargeEventJobService::class.java)
val worker = ChargeEventJobWorker(repository, service, ChargeEventJobTestTransactionManager())
Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenReturn(emptyList())
worker.runPendingJobs()
Mockito.verify(service, Mockito.never()).processJob(Mockito.anyLong())
}
@Test
fun shouldRetryFailedJobWithBackoffAndFailAfterThirdFailure() {
val repository = Mockito.mock(ChargeEventJobRepository::class.java)
val service = Mockito.mock(ChargeEventJobService::class.java)
val worker = ChargeEventJobWorker(repository, service, ChargeEventJobTestTransactionManager())
val retryJob = job(1L, retryCount = 2)
val before = retryJob.nextRetryAt
Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenReturn(listOf(1L))
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(retryJob))
Mockito.doThrow(IllegalStateException("bonus down")).`when`(service).processJob(1L)
worker.runPendingJobs()
assertEquals(ChargeEventJobStatus.FAILED, retryJob.status)
assertEquals(3, retryJob.retryCount)
assertEquals("bonus down", retryJob.lastError)
assertTrue(retryJob.nextRetryAt == before)
}
@Test
fun shouldUseFiveMinutesForFirstWorkerFailureBackoff() {
val repository = Mockito.mock(ChargeEventJobRepository::class.java)
val service = Mockito.mock(ChargeEventJobService::class.java)
val worker = ChargeEventJobWorker(repository, service, ChargeEventJobTestTransactionManager())
val retryJob = job(1L, retryCount = 0)
val before = LocalDateTime.now()
Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenReturn(listOf(1L))
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(retryJob))
Mockito.doThrow(IllegalStateException("bonus down")).`when`(service).processJob(1L)
worker.runPendingJobs()
assertEquals(ChargeEventJobStatus.PENDING, retryJob.status)
assertEquals(1, retryJob.retryCount)
assertTrue(retryJob.nextRetryAt!!.isAfter(before.plusMinutes(4)))
assertTrue(retryJob.nextRetryAt!!.isBefore(LocalDateTime.now().plusMinutes(6)))
}
private fun job(id: Long, retryCount: Int = 0): ChargeEventJob {
return ChargeEventJob(
sourceChargeId = 100L,
memberId = 10L,
chargeEventId = 20L,
jobType = ChargeEventJobType.ACTIVE_CHARGE_EVENT,
idempotencyKey = "charge-event:100:ACTIVE_CHARGE_EVENT:20",
additionalCan = 10,
paymentGateway = PaymentGateway.PG,
container = "pg",
methodSnapshot = "봄 이벤트",
status = ChargeEventJobStatus.PENDING,
retryCount = retryCount,
nextRetryAt = LocalDateTime.now()
).also { it.id = id }
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
}
}
private class ChargeEventJobTestTransactionManager : AbstractPlatformTransactionManager() {
override fun doGetTransaction(): Any = Any()
override fun doBegin(transaction: Any, definition: org.springframework.transaction.TransactionDefinition) {}
override fun doCommit(status: DefaultTransactionStatus) {}
override fun doRollback(status: DefaultTransactionStatus) {}
}