feat(charge): 첫 충전 보너스 신규 생성을 중단한다
This commit is contained in:
+1
-34
@@ -13,7 +13,6 @@ import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -24,7 +23,6 @@ import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.round
|
||||
|
||||
@Service
|
||||
@@ -34,7 +32,6 @@ class ChargeEventJobService(
|
||||
private val chargeRepository: ChargeRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val chargeEventRepository: ChargeEventRepository? = null,
|
||||
private val authRepository: AuthRepository? = null,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher? = null,
|
||||
transactionManager: PlatformTransactionManager? = null
|
||||
) {
|
||||
@@ -121,36 +118,7 @@ class ChargeEventJobService(
|
||||
private fun buildSnapshot(charge: Charge, member: Member): ChargeEventJob? {
|
||||
val paymentGateway = charge.payment?.paymentGateway
|
||||
?: throw SodaException(messageKey = "can.charge.event.not_applied_contact")
|
||||
return if (member.auth != null) {
|
||||
val authRepository = authRepository ?: return null
|
||||
val authDate = authRepository.getOldestCreatedAtByDi(member.auth!!.di)
|
||||
val chargeCount = authRepository.getMemberIdsByDi(member.auth!!.di)
|
||||
.sumOf { id -> chargeRepository.getChargeCountAfterDate(memberId = id, authDate) }
|
||||
if (chargeCount > 1) {
|
||||
buildActiveEventSnapshot(charge, member, paymentGateway)
|
||||
} else {
|
||||
buildFirstChargeSnapshot(charge, member, paymentGateway)
|
||||
}
|
||||
} else {
|
||||
buildActiveEventSnapshot(charge, member, paymentGateway)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildFirstChargeSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob {
|
||||
val additionalCan = ceil(charge.chargeCan * 0.15).toInt()
|
||||
return ChargeEventJob(
|
||||
sourceChargeId = charge.id!!,
|
||||
memberId = member.id!!,
|
||||
chargeEventId = null,
|
||||
jobType = ChargeEventJobType.FIRST_CHARGE,
|
||||
idempotencyKey = idempotencyKey(charge.id!!, ChargeEventJobType.FIRST_CHARGE, null),
|
||||
additionalCan = additionalCan,
|
||||
paymentGateway = paymentGateway,
|
||||
container = container(paymentGateway),
|
||||
methodSnapshot = FIRST_CHARGE_METHOD,
|
||||
status = ChargeEventJobStatus.PROCESSING,
|
||||
processingStartedAt = LocalDateTime.now()
|
||||
)
|
||||
return buildActiveEventSnapshot(charge, member, paymentGateway)
|
||||
}
|
||||
|
||||
private fun buildActiveEventSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob? {
|
||||
@@ -222,7 +190,6 @@ class ChargeEventJobService(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FIRST_CHARGE_METHOD = "첫 충전 이벤트"
|
||||
private const val MAX_ERROR_LENGTH = 1000
|
||||
}
|
||||
}
|
||||
|
||||
+123
@@ -1,14 +1,32 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.event.charge
|
||||
|
||||
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.can.payment.PaymentStatus
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJob
|
||||
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobRepository
|
||||
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobService
|
||||
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobStatus
|
||||
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobType
|
||||
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobWorker
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.transaction.TransactionDefinition
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
|
||||
class AdminChargeEventJobServiceTest {
|
||||
@Test
|
||||
@@ -54,6 +72,111 @@ class AdminChargeEventJobServiceTest {
|
||||
assertEquals(2, pending.retryCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실패한 첫 충전 작업은 관리자 재시도 후 저장 스냅샷 그대로 워커에서 한 번 지급한다")
|
||||
fun shouldRetryFailedFirstChargeJobWithoutChangingSnapshot() {
|
||||
val repository = Mockito.mock(ChargeEventJobRepository::class.java)
|
||||
val chargeRepository = Mockito.mock(ChargeRepository::class.java)
|
||||
val memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||
val service = AdminChargeEventJobService(repository)
|
||||
val notifications = mutableListOf<FcmEvent>()
|
||||
val payoutService = ChargeEventJobService(
|
||||
repository,
|
||||
chargeRepository,
|
||||
memberRepository,
|
||||
applicationEventPublisher = ApplicationEventPublisher { notifications.add(it as FcmEvent) }
|
||||
)
|
||||
val transactionManager = object : AbstractPlatformTransactionManager() {
|
||||
override fun doGetTransaction(): Any = Any()
|
||||
override fun doBegin(transaction: Any, definition: TransactionDefinition) {}
|
||||
override fun doCommit(status: DefaultTransactionStatus) {}
|
||||
override fun doRollback(status: DefaultTransactionStatus) {}
|
||||
}
|
||||
val worker = ChargeEventJobWorker(repository, payoutService, transactionManager)
|
||||
val member = Member(password = "pw", nickname = "tester").also {
|
||||
it.id = 10L
|
||||
it.appleChargeCan = 100
|
||||
}
|
||||
val failed = ChargeEventJob(
|
||||
sourceChargeId = 100L,
|
||||
memberId = 10L,
|
||||
jobType = ChargeEventJobType.FIRST_CHARGE,
|
||||
idempotencyKey = "charge-event:100:FIRST_CHARGE:none",
|
||||
additionalCan = 15,
|
||||
paymentGateway = PaymentGateway.APPLE_IAP,
|
||||
container = "ios",
|
||||
methodSnapshot = "첫 충전 이벤트",
|
||||
status = ChargeEventJobStatus.FAILED,
|
||||
retryCount = 3,
|
||||
nextRetryAt = LocalDateTime.now().minusDays(1),
|
||||
lastError = "bonus down"
|
||||
).also { it.id = 1L }
|
||||
val savedCharges = mutableListOf<Charge>()
|
||||
Mockito.`when`(repository.findByIdForUpdate(1L)).thenReturn(failed)
|
||||
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(failed))
|
||||
Mockito.`when`(
|
||||
repository.findNextPendingJobIdsForUpdate(
|
||||
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now(),
|
||||
Mockito.eq(30)
|
||||
)
|
||||
).thenAnswer {
|
||||
if (failed.status == ChargeEventJobStatus.PENDING) listOf(1L) else emptyList<Long>()
|
||||
}
|
||||
Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(member)
|
||||
Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer {
|
||||
assertEquals(ChargeEventJobStatus.PROCESSING, failed.status)
|
||||
(it.arguments[0] as Charge).also { charge ->
|
||||
charge.id = 200L
|
||||
savedCharges.add(charge)
|
||||
}
|
||||
}
|
||||
val beforeRetry = LocalDateTime.now()
|
||||
|
||||
worker.runPendingJobs()
|
||||
assertEquals(ChargeEventJobStatus.FAILED, failed.status)
|
||||
assertTrue(savedCharges.isEmpty())
|
||||
service.retry(1L)
|
||||
|
||||
assertEquals(ChargeEventJobStatus.PENDING, failed.status)
|
||||
assertEquals(0, failed.retryCount)
|
||||
assertNull(failed.lastError)
|
||||
assertTrue(requireNotNull(failed.nextRetryAt) >= beforeRetry)
|
||||
assertEquals(100L, failed.sourceChargeId)
|
||||
assertEquals(10L, failed.memberId)
|
||||
assertNull(failed.chargeEventId)
|
||||
assertEquals(ChargeEventJobType.FIRST_CHARGE, failed.jobType)
|
||||
assertEquals("charge-event:100:FIRST_CHARGE:none", failed.idempotencyKey)
|
||||
assertEquals(15, failed.additionalCan)
|
||||
assertEquals(PaymentGateway.APPLE_IAP, failed.paymentGateway)
|
||||
assertEquals("ios", failed.container)
|
||||
assertEquals("첫 충전 이벤트", failed.methodSnapshot)
|
||||
|
||||
worker.runPendingJobs()
|
||||
worker.runPendingJobs()
|
||||
payoutService.processJob(1L)
|
||||
|
||||
assertEquals(ChargeEventJobStatus.DONE, failed.status)
|
||||
assertEquals(200L, failed.resultChargeId)
|
||||
assertNotNull(failed.processingStartedAt)
|
||||
assertNotNull(failed.processedAt)
|
||||
assertNull(failed.nextRetryAt)
|
||||
assertEquals(listOf(0, 15, 0), listOf(member.pgRewardCan, member.appleRewardCan, member.googleRewardCan))
|
||||
assertEquals(listOf(0, 100, 0), listOf(member.pgChargeCan, member.appleChargeCan, member.googleChargeCan))
|
||||
val paidCharge = savedCharges.single()
|
||||
assertEquals(0, paidCharge.chargeCan)
|
||||
assertEquals(15, paidCharge.rewardCan)
|
||||
assertEquals(ChargeStatus.EVENT, paidCharge.status)
|
||||
assertEquals(member, paidCharge.member)
|
||||
assertEquals(PaymentGateway.APPLE_IAP, paidCharge.payment?.paymentGateway)
|
||||
assertEquals(PaymentStatus.COMPLETE, paidCharge.payment?.status)
|
||||
assertEquals("첫 충전 이벤트", paidCharge.payment?.method)
|
||||
val notification = notifications.single()
|
||||
assertEquals("can.charge.event.first_title", notification.titleKey)
|
||||
assertEquals("can.charge.event.additional_can_paid", notification.messageKey)
|
||||
assertEquals(listOf(15), notification.args)
|
||||
assertEquals(listOf(10L), notification.recipients)
|
||||
}
|
||||
|
||||
private fun job(id: Long, status: ChargeEventJobStatus): ChargeEventJob {
|
||||
return ChargeEventJob(
|
||||
sourceChargeId = 100L,
|
||||
|
||||
+274
@@ -1,15 +1,31 @@
|
||||
package kr.co.vividnext.sodalive.v2.can.charge.event
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.event.charge.ChargeEvent
|
||||
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.charge.event.ChargeEventRepository
|
||||
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.common.SodaException
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.CsvSource
|
||||
import org.junit.jupiter.params.provider.EnumSource
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.transaction.TransactionDefinition
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus
|
||||
@@ -78,6 +94,264 @@ class ChargeEventJobServiceTest {
|
||||
assertEquals(TransactionDefinition.PROPAGATION_REQUIRES_NEW, transactionTemplate.propagationBehavior)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(PaymentGateway::class, names = ["PG", "PAYVERSE", "APPLE_IAP", "GOOGLE_IAP"])
|
||||
@DisplayName("인증 회원의 첫 충전도 활성 이벤트가 없으면 보너스 작업을 만들지 않는다")
|
||||
fun shouldNotCreateFirstChargeJobForNewAuthenticatedCharge(gateway: PaymentGateway) {
|
||||
val fixture = SnapshotFixture(gateway)
|
||||
|
||||
val result = fixture.service.createAndProcessImmediate(100L, 10L)
|
||||
|
||||
assertNull(result)
|
||||
assertEquals(emptyList<ChargeEventJob>(), fixture.savedJobs)
|
||||
assertEquals(emptyList<Charge>(), fixture.savedCharges)
|
||||
assertEquals(emptyList<FcmEvent>(), fixture.notifications)
|
||||
fixture.assertWallet(1, 2, 3)
|
||||
fixture.assertNoEligibilityLookup()
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"PG, pg, false, 11, 2, 3",
|
||||
"PAYVERSE, pg, false, 11, 2, 3",
|
||||
"APPLE_IAP, ios, false, 1, 12, 3",
|
||||
"GOOGLE_IAP, aos, false, 1, 2, 13",
|
||||
"PG, pg, true, 11, 2, 3",
|
||||
"PAYVERSE, pg, true, 11, 2, 3",
|
||||
"APPLE_IAP, ios, true, 1, 12, 3",
|
||||
"GOOGLE_IAP, aos, true, 1, 2, 13"
|
||||
)
|
||||
@DisplayName("모든 결제 수단과 인증 여부에서 첫 충전 대신 일반 이벤트의 10퍼센트를 지급한다")
|
||||
fun shouldApplyActiveEventToNewChargeAcrossGateways(
|
||||
gateway: PaymentGateway,
|
||||
container: String,
|
||||
authenticated: Boolean,
|
||||
pgReward: Int,
|
||||
appleReward: Int,
|
||||
googleReward: Int
|
||||
) {
|
||||
val fixture = SnapshotFixture(gateway, authenticated)
|
||||
fixture.activeEvent(paymentCount = 1)
|
||||
|
||||
val result = requireNotNull(fixture.service.createAndProcessImmediate(100L, 10L))
|
||||
|
||||
assertEquals(ChargeEventJobType.ACTIVE_CHARGE_EVENT, result.jobType)
|
||||
assertEquals(20L, result.chargeEventId)
|
||||
assertEquals(100L, result.sourceChargeId)
|
||||
assertEquals(10L, result.memberId)
|
||||
assertEquals("charge-event:100:ACTIVE_CHARGE_EVENT:20", result.idempotencyKey)
|
||||
assertEquals(10, result.additionalCan)
|
||||
assertEquals(gateway, result.paymentGateway)
|
||||
assertEquals(container, result.container)
|
||||
assertEquals("봄 이벤트", result.methodSnapshot)
|
||||
assertEquals(listOf(result), fixture.savedJobs)
|
||||
assertEquals(ChargeEventJobStatus.DONE, result.status)
|
||||
fixture.assertPayout(10, gateway, "봄 이벤트")
|
||||
fixture.assertWallet(pgReward, appleReward, googleReward)
|
||||
fixture.assertNotification(10, title = "봄 이벤트")
|
||||
fixture.assertNoEligibilityLookup()
|
||||
Mockito.verify(fixture.eventRepository).getPaymentCount(
|
||||
fixture.member,
|
||||
"봄 이벤트",
|
||||
fixture.event.startDate,
|
||||
fixture.event.endDate
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 이벤트 한도를 소진한 인증 회원은 첫 충전 보너스로 우회하지 않는다")
|
||||
fun shouldNotCreateJobWhenActiveEventLimitIsExhausted() {
|
||||
val fixture = SnapshotFixture(PaymentGateway.PG)
|
||||
fixture.activeEvent(paymentCount = 2)
|
||||
|
||||
val result = fixture.service.createAndProcessImmediate(100L, 10L)
|
||||
|
||||
assertNull(result)
|
||||
assertEquals(emptyList<ChargeEventJob>(), fixture.savedJobs)
|
||||
assertEquals(emptyList<Charge>(), fixture.savedCharges)
|
||||
assertEquals(emptyList<FcmEvent>(), fixture.notifications)
|
||||
fixture.assertWallet(1, 2, 3)
|
||||
fixture.assertNoEligibilityLookup()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("결제 정보가 없으면 활성 이벤트 조회보다 먼저 기존 예외를 유지한다")
|
||||
fun shouldRejectMissingGatewayBeforeLookingUpActiveEvent() {
|
||||
val fixture = SnapshotFixture(PaymentGateway.PG)
|
||||
fixture.sourceCharge.payment = null
|
||||
|
||||
assertThrows(SodaException::class.java) { fixture.service.createProcessingJob(100L, 10L) }
|
||||
|
||||
Mockito.verifyNoInteractions(fixture.eventRepository, fixture.jobRepository)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"PG, pg, PENDING, 16, 2, 3",
|
||||
"PAYVERSE, pg, PENDING, 16, 2, 3",
|
||||
"APPLE_IAP, ios, PENDING, 1, 17, 3",
|
||||
"GOOGLE_IAP, aos, PENDING, 1, 2, 18",
|
||||
"PG, pg, PROCESSING, 16, 2, 3",
|
||||
"PAYVERSE, pg, PROCESSING, 16, 2, 3",
|
||||
"APPLE_IAP, ios, PROCESSING, 1, 17, 3",
|
||||
"GOOGLE_IAP, aos, PROCESSING, 1, 2, 18"
|
||||
)
|
||||
@DisplayName("기존 첫 충전 작업은 현재 이벤트나 인증 재계산 없이 저장된 15캔을 한 번 지급한다")
|
||||
fun shouldPayExistingFirstChargeSnapshotWithoutActiveEvent(
|
||||
gateway: PaymentGateway,
|
||||
container: String,
|
||||
status: ChargeEventJobStatus,
|
||||
pgReward: Int,
|
||||
appleReward: Int,
|
||||
googleReward: Int
|
||||
) {
|
||||
val fixture = SnapshotFixture(gateway)
|
||||
val storedJob = ChargeEventJob(
|
||||
sourceChargeId = 100L,
|
||||
memberId = 10L,
|
||||
jobType = ChargeEventJobType.FIRST_CHARGE,
|
||||
idempotencyKey = "charge-event:100:FIRST_CHARGE:none",
|
||||
additionalCan = 15,
|
||||
paymentGateway = gateway,
|
||||
container = container,
|
||||
methodSnapshot = "첫 충전 이벤트",
|
||||
status = status,
|
||||
nextRetryAt = LocalDateTime.now(),
|
||||
lastError = "previous failure"
|
||||
).also { it.id = 1L }
|
||||
Mockito.`when`(fixture.jobRepository.findByIdForUpdate(1L)).thenReturn(storedJob)
|
||||
|
||||
fixture.service.processJob(1L)
|
||||
fixture.service.processJob(1L)
|
||||
|
||||
assertEquals(ChargeEventJobStatus.DONE, storedJob.status)
|
||||
assertEquals(15, storedJob.additionalCan)
|
||||
assertEquals(gateway, storedJob.paymentGateway)
|
||||
assertEquals(container, storedJob.container)
|
||||
assertEquals("첫 충전 이벤트", storedJob.methodSnapshot)
|
||||
assertEquals(200L, storedJob.resultChargeId)
|
||||
assertNotNull(storedJob.processedAt)
|
||||
assertNull(storedJob.nextRetryAt)
|
||||
assertNull(storedJob.lastError)
|
||||
assertEquals(emptyList<ChargeEventJob>(), fixture.savedJobs)
|
||||
fixture.assertPayout(15, gateway, "첫 충전 이벤트")
|
||||
fixture.assertWallet(pgReward, appleReward, googleReward)
|
||||
fixture.assertNotification(15, titleKey = "can.charge.event.first_title")
|
||||
fixture.assertNoEligibilityLookup()
|
||||
Mockito.verifyNoInteractions(fixture.eventRepository)
|
||||
Mockito.verify(fixture.chargeRepository, Mockito.never()).findByIdForUpdate(Mockito.anyLong())
|
||||
}
|
||||
|
||||
private class SnapshotFixture(gateway: PaymentGateway, authenticated: Boolean = true) {
|
||||
val jobRepository = Mockito.mock(ChargeEventJobRepository::class.java)
|
||||
val chargeRepository = Mockito.mock(ChargeRepository::class.java)
|
||||
private val memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||
val eventRepository = Mockito.mock(ChargeEventRepository::class.java)
|
||||
val savedJobs = mutableListOf<ChargeEventJob>()
|
||||
val savedCharges = mutableListOf<Charge>()
|
||||
val notifications = mutableListOf<FcmEvent>()
|
||||
val member = Member(password = "pw", nickname = "tester").also {
|
||||
it.id = 10L
|
||||
it.pgChargeCan = 100
|
||||
it.appleChargeCan = 200
|
||||
it.googleChargeCan = 300
|
||||
it.pgRewardCan = 1
|
||||
it.appleRewardCan = 2
|
||||
it.googleRewardCan = 3
|
||||
if (authenticated) {
|
||||
Auth("tester", "19900101", "test-ci", "test-di", 1).member = it
|
||||
}
|
||||
}
|
||||
val sourceCharge = Charge(100, 5).also {
|
||||
it.id = 100L
|
||||
it.member = member
|
||||
it.payment = Payment(PaymentStatus.COMPLETE, gateway).also { payment -> payment.method = "카드" }
|
||||
}
|
||||
val event = ChargeEvent(
|
||||
"봄 이벤트",
|
||||
LocalDateTime.of(2026, 1, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 12, 31, 0, 0),
|
||||
2,
|
||||
0.10f
|
||||
).also { it.id = 20L }
|
||||
val service = ChargeEventJobService(
|
||||
jobRepository,
|
||||
chargeRepository,
|
||||
memberRepository,
|
||||
chargeEventRepository = eventRepository,
|
||||
applicationEventPublisher = ApplicationEventPublisher { notifications.add(it as FcmEvent) }
|
||||
)
|
||||
|
||||
init {
|
||||
Mockito.`when`(chargeRepository.findByIdForUpdate(100L)).thenReturn(sourceCharge)
|
||||
Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(member)
|
||||
Mockito.`when`(jobRepository.saveAndFlush(Mockito.any(ChargeEventJob::class.java))).thenAnswer {
|
||||
(it.arguments[0] as ChargeEventJob).also { job ->
|
||||
job.id = 1L
|
||||
savedJobs.add(job)
|
||||
}
|
||||
}
|
||||
Mockito.`when`(jobRepository.findByIdForUpdate(1L)).thenAnswer { savedJobs.singleOrNull() }
|
||||
Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer {
|
||||
(it.arguments[0] as Charge).also { charge ->
|
||||
charge.id = 200L
|
||||
savedCharges.add(charge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun activeEvent(paymentCount: Int) {
|
||||
Mockito.`when`(eventRepository.getChargeEvent()).thenReturn(event)
|
||||
Mockito.`when`(
|
||||
eventRepository.getPaymentCount(member, "봄 이벤트", event.startDate, event.endDate)
|
||||
).thenReturn(paymentCount)
|
||||
}
|
||||
|
||||
fun assertNoEligibilityLookup() {
|
||||
Mockito.verify(chargeRepository, Mockito.never()).getChargeCountAfterDate(
|
||||
Mockito.anyLong(),
|
||||
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
||||
)
|
||||
}
|
||||
|
||||
fun assertWallet(pgReward: Int, appleReward: Int, googleReward: Int) {
|
||||
assertEquals(
|
||||
listOf(pgReward, appleReward, googleReward),
|
||||
listOf(member.pgRewardCan, member.appleRewardCan, member.googleRewardCan)
|
||||
)
|
||||
assertEquals(listOf(100, 200, 300), listOf(member.pgChargeCan, member.appleChargeCan, member.googleChargeCan))
|
||||
assertEquals(100, sourceCharge.chargeCan)
|
||||
assertEquals(5, sourceCharge.rewardCan)
|
||||
assertEquals(ChargeStatus.CHARGE, sourceCharge.status)
|
||||
assertEquals(PaymentStatus.COMPLETE, sourceCharge.payment?.status)
|
||||
assertEquals("카드", sourceCharge.payment?.method)
|
||||
}
|
||||
|
||||
fun assertPayout(amount: Int, gateway: PaymentGateway, method: String) {
|
||||
val paidCharge = savedCharges.single()
|
||||
assertEquals(0, paidCharge.chargeCan)
|
||||
assertEquals(amount, paidCharge.rewardCan)
|
||||
assertEquals(ChargeStatus.EVENT, paidCharge.status)
|
||||
assertEquals("$amount 캔", paidCharge.title)
|
||||
assertEquals(member, paidCharge.member)
|
||||
assertEquals(gateway, paidCharge.payment?.paymentGateway)
|
||||
assertEquals(PaymentStatus.COMPLETE, paidCharge.payment?.status)
|
||||
assertEquals(method, paidCharge.payment?.method)
|
||||
}
|
||||
|
||||
fun assertNotification(amount: Int, title: String = "", titleKey: String? = null) {
|
||||
val notification = notifications.single()
|
||||
assertEquals(FcmEventType.INDIVIDUAL, notification.type)
|
||||
assertEquals(PushNotificationCategory.SYSTEM, notification.category)
|
||||
assertEquals(title, notification.title)
|
||||
assertEquals(titleKey, notification.titleKey)
|
||||
assertEquals("can.charge.event.additional_can_paid", notification.messageKey)
|
||||
assertEquals(listOf(amount), notification.args)
|
||||
assertEquals(listOf(10L), notification.recipients)
|
||||
assertNull(notification.isAuth)
|
||||
}
|
||||
}
|
||||
|
||||
private fun job(
|
||||
id: Long,
|
||||
memberId: Long = 10L,
|
||||
|
||||
+79
@@ -1,10 +1,20 @@
|
||||
package kr.co.vividnext.sodalive.v2.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.can.payment.PaymentStatus
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
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.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus
|
||||
@@ -92,6 +102,75 @@ class ChargeEventJobWorkerTest {
|
||||
assertTrue(retryJob.nextRetryAt!!.isBefore(LocalDateTime.now().plusMinutes(6)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("워커는 저장된 첫 충전 대기 작업을 지급하고 완료 작업 재호출에는 중복 지급하지 않는다")
|
||||
fun shouldProcessPendingFirstChargeJobAndSkipDoneJob() {
|
||||
val repository = Mockito.mock(ChargeEventJobRepository::class.java)
|
||||
val chargeRepository = Mockito.mock(ChargeRepository::class.java)
|
||||
val memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||
val notifications = mutableListOf<FcmEvent>()
|
||||
val service = ChargeEventJobService(
|
||||
repository,
|
||||
chargeRepository,
|
||||
memberRepository,
|
||||
applicationEventPublisher = ApplicationEventPublisher { notifications.add(it as FcmEvent) }
|
||||
)
|
||||
val worker = ChargeEventJobWorker(repository, service, ChargeEventJobTestTransactionManager())
|
||||
val member = Member(password = "pw", nickname = "tester").also {
|
||||
it.id = 10L
|
||||
it.pgChargeCan = 100
|
||||
}
|
||||
val pending = ChargeEventJob(
|
||||
sourceChargeId = 100L,
|
||||
memberId = 10L,
|
||||
jobType = ChargeEventJobType.FIRST_CHARGE,
|
||||
idempotencyKey = "charge-event:100:FIRST_CHARGE:none",
|
||||
additionalCan = 15,
|
||||
paymentGateway = PaymentGateway.PAYVERSE,
|
||||
container = "pg",
|
||||
methodSnapshot = "첫 충전 이벤트",
|
||||
nextRetryAt = LocalDateTime.now().minusMinutes(1)
|
||||
).also { it.id = 1L }
|
||||
val savedCharges = mutableListOf<Charge>()
|
||||
Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenAnswer {
|
||||
if (pending.status == ChargeEventJobStatus.PENDING) listOf(1L) else emptyList<Long>()
|
||||
}
|
||||
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(pending))
|
||||
Mockito.`when`(repository.findByIdForUpdate(1L)).thenReturn(pending)
|
||||
Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(member)
|
||||
Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer {
|
||||
assertEquals(ChargeEventJobStatus.PROCESSING, pending.status)
|
||||
(it.arguments[0] as Charge).also { charge ->
|
||||
charge.id = 200L
|
||||
savedCharges.add(charge)
|
||||
}
|
||||
}
|
||||
|
||||
worker.runPendingJobs()
|
||||
worker.runPendingJobs()
|
||||
service.processJob(1L)
|
||||
|
||||
assertEquals(ChargeEventJobStatus.DONE, pending.status)
|
||||
assertNotNull(pending.processingStartedAt)
|
||||
assertNotNull(pending.processedAt)
|
||||
assertEquals(200L, pending.resultChargeId)
|
||||
assertEquals(listOf(15, 0, 0), listOf(member.pgRewardCan, member.appleRewardCan, member.googleRewardCan))
|
||||
assertEquals(listOf(100, 0, 0), listOf(member.pgChargeCan, member.appleChargeCan, member.googleChargeCan))
|
||||
val paidCharge = savedCharges.single()
|
||||
assertEquals(0, paidCharge.chargeCan)
|
||||
assertEquals(15, paidCharge.rewardCan)
|
||||
assertEquals(ChargeStatus.EVENT, paidCharge.status)
|
||||
assertEquals(member, paidCharge.member)
|
||||
assertEquals(PaymentGateway.PAYVERSE, paidCharge.payment?.paymentGateway)
|
||||
assertEquals(PaymentStatus.COMPLETE, paidCharge.payment?.status)
|
||||
assertEquals("첫 충전 이벤트", paidCharge.payment?.method)
|
||||
val notification = notifications.single()
|
||||
assertEquals("can.charge.event.first_title", notification.titleKey)
|
||||
assertEquals("can.charge.event.additional_can_paid", notification.messageKey)
|
||||
assertEquals(listOf(15), notification.args)
|
||||
assertEquals(listOf(10L), notification.recipients)
|
||||
}
|
||||
|
||||
private fun job(id: Long, retryCount: Int = 0): ChargeEventJob {
|
||||
return ChargeEventJob(
|
||||
sourceChargeId = 100L,
|
||||
|
||||
Reference in New Issue
Block a user