fix(charge): 충전 이벤트 보너스 지급을 안정화한다
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user