Test #460

Merged
klaus merged 8 commits from test into main 2026-10-26 04:58:47 +00:00
8 changed files with 1360 additions and 66 deletions
Showing only changes of commit 8b2ac38b83 - Show all commits
@@ -1,9 +1,26 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import javax.persistence.EntityManager
import javax.persistence.LockModeType
interface GiftDeliveryRepository : JpaRepository<GiftDelivery, Long> {
interface GiftDeliveryRepository : JpaRepository<GiftDelivery, Long>, GiftDeliveryLockRepository {
fun findByGiftId(giftId: Long): GiftDelivery?
fun findByGiftIdIn(giftIds: Collection<Long>): List<GiftDelivery>
}
interface GiftDeliveryLockRepository {
fun findByGiftIdForUpdate(giftId: Long): GiftDelivery?
}
@Repository
class GiftDeliveryLockRepositoryImpl(private val entityManager: EntityManager) : GiftDeliveryLockRepository {
override fun findByGiftIdForUpdate(giftId: Long): GiftDelivery? = entityManager
.createQuery("select d from GiftDelivery d where d.giftId = :giftId", GiftDelivery::class.java)
.setParameter("giftId", giftId)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.resultList.singleOrNull()
?.also { entityManager.refresh(it, LockModeType.PESSIMISTIC_WRITE) }
}
@@ -7,16 +7,14 @@ import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import javax.persistence.EntityManager
import javax.persistence.LockModeType
interface GiftRepository : JpaRepository<Gift, Long> {
interface GiftRepository : JpaRepository<Gift, Long>, GiftLockRepository {
fun findByApplicationNo(applicationNo: String): Gift?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select g from Gift g where g.applicationNo = :applicationNo")
fun findByApplicationNoForUpdate(@Param("applicationNo") applicationNo: String): Gift?
fun findBySenderMemberId(senderMemberId: Long, pageable: Pageable): Page<Gift>
fun findByRecipientMemberIdAndStatusIn(
@@ -67,6 +65,7 @@ interface GiftRepository : JpaRepository<Gift, Long> {
pageable: Pageable
): Page<Gift>
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(
"""
select g
@@ -78,9 +77,9 @@ interface GiftRepository : JpaRepository<Gift, Long> {
where d.giftId = g.id
and d.trackingDeadlineAt < :now
)
order by g.id
"""
)
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findReceivedGiftsWithTrackingDeadlineBefore(@Param("now") now: LocalDateTime): List<Gift>
@Query(
@@ -105,6 +104,22 @@ interface GiftRepository : JpaRepository<Gift, Long> {
@Param("now") now: LocalDateTime
): List<Gift>
@Query(
"""
select g
from Gift g
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.DELIVERED
and exists (
select d.id
from GiftDelivery d
where d.giftId = g.id
and d.deliveredAt is not null
and d.deliveredAt <= :cutoff
)
"""
)
fun findDeliveredGiftsWithDeliveryBeforeOrAt(@Param("cutoff") cutoff: LocalDateTime): List<Gift>
@Query(
"""
select g
@@ -120,7 +135,6 @@ interface GiftRepository : JpaRepository<Gift, Long> {
)
"""
)
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findReceivedGiftsWithTrackingDeadlineReminderDue(
@Param("now") now: LocalDateTime,
@Param("reminderUntil") reminderUntil: LocalDateTime
@@ -151,3 +165,17 @@ interface GiftRepository : JpaRepository<Gift, Long> {
@Param("reminderUntil") reminderUntil: LocalDateTime
): List<Gift>
}
interface GiftLockRepository {
fun findByApplicationNoForUpdate(applicationNo: String): Gift?
}
@Repository
class GiftLockRepositoryImpl(private val entityManager: EntityManager) : GiftLockRepository {
override fun findByApplicationNoForUpdate(applicationNo: String): Gift? = entityManager
.createQuery("select g from Gift g where g.applicationNo = :applicationNo", Gift::class.java)
.setParameter("applicationNo", applicationNo)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.resultList.singleOrNull()
?.also { entityManager.refresh(it, LockModeType.PESSIMISTIC_WRITE) }
}
@@ -5,10 +5,13 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepo
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.redisson.api.RLock
import org.redisson.api.RedissonClient
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime
import java.util.concurrent.TimeUnit
@@ -39,63 +42,183 @@ class GiftScheduler(
@Transactional
fun cancelExpiredTrackingRegistrationGifts(now: LocalDateTime): Int {
val expiredGifts = giftRepository.findReceivedGiftsWithTrackingDeadlineBefore(now)
expiredGifts.forEach { gift ->
return expiredGifts.sortedBy { it.id }.count { candidate ->
val gift = requireNotNull(giftRepository.findByApplicationNoForUpdate(candidate.applicationNo))
val delivery = requireNotNull(giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)))
if (gift.status != GiftStatus.RECEIVED || !delivery.trackingDeadlineAt.isBefore(now)) {
return@count false
}
gift.status = GiftStatus.CANCELED
requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))).canceledAt = now
delivery.canceledAt = now
canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId)
giftPushService.sendAutoCanceledToSender(gift.applicationNo, gift.senderMemberId)
true
}
}
@Scheduled(cron = "0 10 * * * *", zone = "Asia/Seoul")
@Transactional
fun runExpiredRecipientAddressGiftExpiration() {
val lock = redissonClient.getLock(EXPIRED_RECIPIENT_ADDRESS_GIFTS_LOCK_KEY)
if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) {
return
}
return expiredGifts.size
try {
markExpiredRecipientAddressGiftsUndeliverable(LocalDateTime.now())
} finally {
unlockAfterTransaction(lock)
}
}
@Transactional
fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int {
val expiredGifts = giftRepository.findGiftsWithRecipientAddressDeadlineBefore(now)
expiredGifts.forEach { gift ->
return expiredGifts.sortedBy { it.id }.count { candidate ->
val gift = requireNotNull(giftRepository.findByApplicationNoForUpdate(candidate.applicationNo))
val delivery = requireNotNull(giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)))
if (gift.status !in RECIPIENT_ADDRESS_STATUSES || delivery.recipientAddress != null ||
delivery.recipientAddressDeadlineAt?.isBefore(now) != true
) {
return@count false
}
gift.status = GiftStatus.UNDELIVERABLE
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
delivery.undeliverableAt = now
delivery.undeliverableReason = EXPIRED_RECIPIENT_ADDRESS_REASON
true
}
}
@Scheduled(cron = "0 20 * * * *", zone = "Asia/Seoul")
@Transactional
fun runDeliveredGiftPersonalDataErasure() {
val lock = redissonClient.getLock(DELIVERED_GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY)
if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) {
return
}
return expiredGifts.size
try {
eraseDeliveredGiftPersonalData(LocalDateTime.now())
} finally {
unlockAfterTransaction(lock)
}
}
@Transactional
fun eraseDeliveredGiftPersonalData(now: LocalDateTime): Int {
val cutoff = now.minusDays(7)
val expiredGifts = giftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(cutoff)
return expiredGifts.count { gift ->
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
if (
listOf(
delivery.senderName,
delivery.senderPhoneNumber,
delivery.senderZipCode,
delivery.senderAddress,
delivery.senderAddressDetail,
delivery.recipientName,
delivery.recipientPhoneNumber,
delivery.recipientZipCode,
delivery.recipientAddress,
delivery.recipientAddressDetail,
delivery.courierCompanyName,
delivery.trackingNumber
).all { it == null }
) {
false
} else {
delivery.senderName = null
delivery.senderPhoneNumber = null
delivery.senderZipCode = null
delivery.senderAddress = null
delivery.senderAddressDetail = null
delivery.recipientName = null
delivery.recipientPhoneNumber = null
delivery.recipientZipCode = null
delivery.recipientAddress = null
delivery.recipientAddressDetail = null
delivery.courierCompanyName = null
delivery.trackingNumber = null
true
}
}
}
@Scheduled(cron = "0 40 * * * *", zone = "Asia/Seoul")
@Transactional
fun runDeadlineReminderGifts() {
val lock = redissonClient.getLock(DEADLINE_REMINDER_GIFTS_LOCK_KEY)
if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) {
return
}
try {
sendDeadlineReminderGifts(LocalDateTime.now())
} finally {
unlockAfterTransaction(lock)
}
}
@Transactional
fun sendDeadlineReminderGifts(now: LocalDateTime): Int {
val reminderUntil = now.plusHours(DEADLINE_REMINDER_HOURS)
val trackingReminderCount = sendTrackingDeadlineReminders(now, reminderUntil)
val recipientAddressReminderCount = sendRecipientAddressDeadlineReminders(now, reminderUntil)
return trackingReminderCount + recipientAddressReminderCount
val gifts = giftRepository.findReceivedGiftsWithTrackingDeadlineReminderDue(now, reminderUntil) +
giftRepository.findGiftsWithRecipientAddressDeadlineReminderDue(now, reminderUntil)
return gifts.distinctBy { it.id }.sortedBy { it.id }.count { candidate ->
val gift = requireNotNull(giftRepository.findByApplicationNoForUpdate(candidate.applicationNo))
val delivery = requireNotNull(giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)))
val recipientDeadline = delivery.recipientAddressDeadlineAt
when {
gift.status == GiftStatus.RECEIVED && delivery.trackingDeadlineAt.isAfter(now) &&
!delivery.trackingDeadlineAt.isAfter(reminderUntil) && delivery.trackingDeadlineReminderSentAt == null -> {
delivery.trackingDeadlineReminderSentAt = now
giftPushService.sendTrackingDeadlineReminder(gift.applicationNo, gift.senderMemberId)
true
}
gift.status in RECIPIENT_ADDRESS_STATUSES && delivery.recipientAddress == null &&
recipientDeadline != null && recipientDeadline.isAfter(now) && !recipientDeadline.isAfter(reminderUntil) &&
delivery.recipientAddressDeadlineReminderSentAt == null -> {
delivery.recipientAddressDeadlineReminderSentAt = now
giftPushService.sendRecipientAddressDeadlineReminder(gift.applicationNo, gift.recipientMemberId)
true
}
else -> false
}
}
}
private fun sendTrackingDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int {
val gifts = giftRepository.findReceivedGiftsWithTrackingDeadlineReminderDue(now, reminderUntil)
gifts.forEach { gift ->
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
delivery.trackingDeadlineReminderSentAt = now
giftPushService.sendTrackingDeadlineReminder(gift.applicationNo, gift.senderMemberId)
private fun unlockAfterTransaction(lock: RLock) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
return
}
return gifts.size
}
private fun sendRecipientAddressDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int {
val gifts = giftRepository.findGiftsWithRecipientAddressDeadlineReminderDue(now, reminderUntil)
gifts.forEach { gift ->
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
delivery.recipientAddressDeadlineReminderSentAt = now
giftPushService.sendRecipientAddressDeadlineReminder(gift.applicationNo, gift.recipientMemberId)
}
return gifts.size
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCompletion(status: Int) {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}
)
}
private companion object {
val RECIPIENT_ADDRESS_STATUSES = setOf(
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED
)
const val DEADLINE_REMINDER_HOURS = 24L
const val EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY = "lock:gift-expired-tracking-registration-cancellation"
const val EXPIRED_RECIPIENT_ADDRESS_GIFTS_LOCK_KEY = "lock:gift-expired-recipient-address-undeliverable"
const val DELIVERED_GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY = "lock:gift-delivered-personal-data-erasure"
const val DEADLINE_REMINDER_GIFTS_LOCK_KEY = "lock:gift-deadline-reminders"
const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과"
}
}
@@ -157,7 +157,7 @@ class GiftAdminService(
fun cancelGift(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
if (gift.status != GiftStatus.RECEIVED) throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest()
val canceledAt = now.truncatedTo(ChronoUnit.SECONDS)
gift.status = GiftStatus.CANCELED
@@ -169,9 +169,9 @@ class GiftAdminService(
@Transactional
fun arriveMailbox(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
if (gift.status != GiftStatus.TRACKING_REGISTERED) throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest()
gift.status = GiftStatus.ARRIVED_AT_MAILBOX
delivery.arrivedAtMailboxAt = now
@@ -183,9 +183,9 @@ class GiftAdminService(
@Transactional
fun completeInspection(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
if (gift.status != GiftStatus.ARRIVED_AT_MAILBOX) throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest()
gift.status = GiftStatus.INSPECTION_COMPLETED
delivery.inspectionCompletedAt = now
@@ -200,11 +200,11 @@ class GiftAdminService(
reason: String,
now: LocalDateTime = LocalDateTime.now()
): GiftOperationStatusResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
if (gift.status !in UNDELIVERABLE_ALLOWED_STATUSES || reason.isBlank() || reason.length > 255) {
throw invalidRequest()
}
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest()
gift.status = GiftStatus.UNDELIVERABLE
delivery.undeliverableAt = now
@@ -217,9 +217,9 @@ class GiftAdminService(
@Transactional
fun completeDelivery(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
if (gift.status != GiftStatus.INSPECTION_COMPLETED) throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest()
if (!delivery.hasCompleteRecipientInformation()) throw invalidRequest()
gift.status = GiftStatus.DELIVERED
@@ -212,7 +212,7 @@ class GiftCommandService(
val canceledAt = now()
gift.status = GiftStatus.CANCELED
deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest())?.canceledAt = canceledAt
deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest())?.canceledAt = canceledAt
canPaymentService.refundGiftCan(senderMemberId, gift.canUsageId)
return GiftCancellationResult(
@@ -234,7 +234,7 @@ class GiftCommandService(
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest()
validate(command)
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val delivery = deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val trackingRegisteredAt = now()
val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7)
@@ -263,12 +263,12 @@ class GiftCommandService(
command: GiftRecipientAddressRegistrationCommand
): GiftRecipientAddressRegistrationResult {
val recipientMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
if (gift.recipientMemberId != recipientMemberId || gift.status !in RECIPIENT_ADDRESS_REGISTRABLE_STATUSES) {
throw invalidRequest()
}
validate(command)
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val delivery = deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val registeredAt = now()
delivery.recipientName = command.name
@@ -292,11 +292,11 @@ class GiftCommandService(
@Transactional
fun confirmDelivery(member: Member, applicationNo: String): GiftDeliveryConfirmationResult {
val recipientMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
if (gift.recipientMemberId != recipientMemberId || gift.status != GiftStatus.INSPECTION_COMPLETED) {
throw invalidRequest()
}
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val delivery = deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val deliveredAt = now()
gift.status = GiftStatus.DELIVERED
@@ -28,15 +28,19 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepo
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftScheduler
import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService
import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.hamcrest.Matchers.endsWith
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -50,6 +54,8 @@ import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import org.junit.jupiter.params.provider.MethodSource
import org.mockito.Mockito
import org.springframework.aop.framework.Advised
import org.springframework.aop.support.AopUtils
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
@@ -63,6 +69,9 @@ import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor
import org.springframework.scheduling.config.CronTask
import org.springframework.scheduling.support.ScheduledMethodRunnable
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
@@ -78,6 +87,8 @@ import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.interceptor.TransactionInterceptor
import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
@@ -113,12 +124,14 @@ class AdminGiftControllerTest @Autowired constructor(
private val useCanRepository: UseCanRepository,
private val calculateRepository: UseCanCalculateRepository,
private val commandService: GiftCommandService,
private val adminService: GiftAdminService,
private val scheduler: GiftScheduler,
private val transactionManager: PlatformTransactionManager,
private val entityManager: EntityManager,
private val objectMapper: ObjectMapper,
private val restTemplate: TestRestTemplate,
private val tokenProvider: TokenProvider
private val tokenProvider: TokenProvider,
private val scheduledProcessor: ScheduledAnnotationBeanPostProcessor
) {
@LocalServerPort
private var port: Int = 0
@@ -131,6 +144,25 @@ class AdminGiftControllerTest @Autowired constructor(
@BeforeEach
fun deleteGiftFixtures() {
val scheduledJobs = scheduledProcessor.scheduledTasks.filter {
(it.task.runnable as? ScheduledMethodRunnable)?.target === scheduler
}
assertTrue(AopUtils.isAopProxy(scheduler))
assertTrue((scheduler as Advised).advisors.any { it.advice is TransactionInterceptor })
assertEquals(4, scheduledJobs.size)
assertEquals(
mapOf(
"runExpiredTrackingRegistrationGiftCancellation" to "0 0 * * * *",
"runExpiredRecipientAddressGiftExpiration" to "0 10 * * * *",
"runDeliveredGiftPersonalDataErasure" to "0 20 * * * *",
"runDeadlineReminderGifts" to "0 40 * * * *"
),
scheduledJobs.associate {
(it.task.runnable as ScheduledMethodRunnable).method.name to (it.task as CronTask).expression
}
)
println("P15_SPRING transactionalProxy=true registeredMinutes=00,10,20,40 cancelingTestJobs=${scheduledJobs.size}")
scheduledJobs.forEach { it.cancel() }
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest()))
giftDeliveryRepository.deleteAll()
giftRepository.deleteAll()
@@ -270,6 +302,248 @@ class AdminGiftControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.availableActions[1]").value("MARK_UNDELIVERABLE"))
}
@Test
@DisplayName("관리자는 개인정보가 지워진 전달 완료 선물을 기존 빈 문자열과 null 응답으로 조회한다")
fun shouldReturnCompatibleErasedAdminGiftResponses() {
val (gift, delivery) = saveAdminGift(
applicationNo = "A-ERASED-CONTROLLER",
status = GiftStatus.DELIVERED,
senderNickname = "erased-admin-fan",
recipientNickname = "erased-admin-creator",
withTracking = false
)
delivery.senderName = null
delivery.senderPhoneNumber = null
delivery.senderZipCode = null
delivery.senderAddress = null
delivery.senderAddressDetail = null
giftDeliveryRepository.saveAndFlush(delivery)
mockMvc.perform(get("/api/v2/admin/gifts/{applicationNo}", gift.applicationNo).with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("DELIVERED"))
.andExpect(jsonPath("$.data.senderInfo.nickname").value("erased-admin-fan"))
.andExpect(jsonPath("$.data.senderInfo.name").value(""))
.andExpect(jsonPath("$.data.senderInfo.phoneNumber").value(""))
.andExpect(jsonPath("$.data.senderInfo.address").value(""))
.andExpect(jsonPath("$.data.recipientInfo.nickname").value("erased-admin-creator"))
.andExpect(jsonPath("$.data.recipientInfo.name").value(""))
.andExpect(jsonPath("$.data.recipientInfo.phoneNumber").value(""))
.andExpect(jsonPath("$.data.recipientInfo.address").value(""))
.andExpect(jsonPath("$.data.inboundDeliveryInfo.courierCompanyName").value(nullValue()))
.andExpect(jsonPath("$.data.inboundDeliveryInfo.trackingNumber").value(nullValue()))
.andExpect(jsonPath("$.data.availableActions").isEmpty)
mockMvc.perform(
get("/api/v2/admin/gifts")
.with(user("admin").roles("ADMIN"))
.param("applicationNo", gift.applicationNo)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].senderNickname").value("erased-admin-fan"))
.andExpect(jsonPath("$.data.items[0].recipientNickname").value("erased-admin-creator"))
.andExpect(jsonPath("$.data.items[0].courierCompanyName").value(nullValue()))
.andExpect(jsonPath("$.data.items[0].trackingNumber").value(nullValue()))
.andExpect(jsonPath("$.data.items[0].availableActions").isEmpty)
}
@Test
@DisplayName("실제 Spring 개인정보 파기는 JWT HTTP 선물함 계약과 결제·동의·리뷰·이력을 보존한다")
fun shouldEraseDeliveredGiftAndPreserveRealJwtHttpContracts() {
val now = NOW.plusDays(14)
val recipient = memberRepository.saveAndFlush(
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "erasure-recipient")
)
val unrelated = memberRepository.saveAndFlush(
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "erasure-unrelated")
)
val fixture = saveFundedGift(recipient)
assertEquals(4, setOf(fixture.sender.id, recipient.id, fixture.admin.id, unrelated.id).size)
val giftId = requireNotNull(fixture.gift.id)
val transaction = TransactionTemplate(transactionManager)
val review = requireNotNull(
transaction.execute {
val gift = giftRepository.findById(giftId).orElseThrow()
gift.status = GiftStatus.DELIVERED
gift.recipientTermsAgreed = true
gift.recipientPrivacyAgreed = true
gift.recipientTermsAgreedAt = NOW
gift.recipientPrivacyAgreedAt = NOW
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(giftId))
delivery.senderAddressDetail = "101동 1001호"
delivery.recipientName = "김소다"
delivery.recipientPhoneNumber = "01098765432"
delivery.recipientZipCode = "04524"
delivery.recipientAddress = "서울시 중구"
delivery.recipientAddressDetail = "202호"
delivery.courierCompanyName = "CJ대한통운"
delivery.trackingNumber = "1234567890"
delivery.trackingRegisteredAt = NOW.minusDays(2)
delivery.arrivedAtMailboxAt = NOW.minusDays(1)
delivery.inspectionCompletedAt = NOW.plusDays(1)
delivery.deliveredAt = now.minusDays(7)
delivery.recipientAddressDeadlineAt = NOW.plusDays(5)
delivery.trackingDeadlineReminderSentAt = NOW.minusDays(3)
delivery.recipientAddressDeadlineReminderSentAt = NOW.plusDays(4)
GiftReview(giftId, requireNotNull(fixture.sender.id), 5, "SAFE", "retained review").also {
entityManager.persist(it)
}
}
)
val personalFields = listOf(
"senderName", "senderPhoneNumber", "senderZipCode", "senderAddress", "senderAddressDetail",
"recipientName", "recipientPhoneNumber", "recipientZipCode", "recipientAddress", "recipientAddressDetail",
"courierCompanyName", "trackingNumber"
)
val originalGift = objectMapper.valueToTree<JsonNode>(giftRepository.findById(giftId).orElseThrow())
val originalDelivery = objectMapper.valueToTree<ObjectNode>(giftDeliveryRepository.findByGiftId(giftId))
personalFields.forEach {
assertTrue(originalDelivery.path(it).isTextual && originalDelivery.path(it).textValue().isNotBlank())
}
originalDelivery.remove(personalFields + "updatedAt")
val originalReview = transaction.execute {
objectMapper.valueToTree<JsonNode>(requireNotNull(entityManager.find(GiftReview::class.java, review.id)))
}
assertRefundState(fixture, refunded = false)
val senderToken = tokenFor(fixture.sender)
val recipientToken = tokenFor(recipient)
val adminToken = tokenFor(fixture.admin)
val unrelatedToken = tokenFor(unrelated)
val detailPath = "/api/v2/gifts/$APPLICATION_NO"
val adminPath = "/api/v2/admin/gifts/$APPLICATION_NO"
val adminListPath = "/api/v2/admin/gifts?applicationNo=$APPLICATION_NO"
val senderAddress = objectMapper.readTree(
"""{"name":"홍길동","phoneNumber":"01012345678","address":"(06234) 서울시 강남구, 101동 1001호"}"""
)
val recipientAddress = objectMapper.readTree(
"""{"name":"김소다","phoneNumber":"01098765432","address":"(04524) 서울시 중구, 202호"}"""
)
val emptyAddress = objectMapper.readTree("""{"name":"","phoneNumber":"","address":""}""")
val json = objectMapper.nodeFactory
var originalTimeline: JsonNode? = null
listOf(false, true).forEach { erased ->
if (erased) {
assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(now))
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(now))
assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
}
listOf(detailPath, adminPath, adminListPath, "/api/v2/gifts?type=SENT", "/api/v2/gifts?type=RECEIVED").forEach {
giftHttp(HttpMethod.GET, it, expectedStatus = HttpStatus.UNAUTHORIZED)
}
listOf(detailPath, adminPath, adminListPath).forEach { path ->
val denied = giftHttp(HttpMethod.GET, path, unrelatedToken)
assertEquals(json.booleanNode(false), denied.path("success"))
assertEquals(json.nullNode(), denied.path("data"))
assertEquals(
json.textNode(if (path == detailPath) "Invalid request." else "You do not have permission."),
denied.path("message")
)
}
val sender = giftHttp(HttpMethod.GET, detailPath, senderToken)
val received = giftHttp(HttpMethod.GET, detailPath, recipientToken)
val admin = giftHttp(HttpMethod.GET, adminPath, adminToken)
listOf(sender, received, admin).forEach { assertEquals(json.booleanNode(true), it.path("success")) }
val sentData = sender.path("data")
val receivedData = received.path("data")
val adminData = admin.path("data")
assertEquals(if (erased) emptyAddress else senderAddress, sentData.path("senderInfo"))
assertEquals(json.nullNode(), sentData.path("recipientAddress"))
assertEquals(json.nullNode(), receivedData.path("senderInfo"))
assertEquals(if (erased) json.nullNode() else recipientAddress, receivedData.path("recipientAddress"))
assertEquals(json.nullNode(), receivedData.path("giftInfo").path("tracking"))
assertEquals(json.textNode("SENT"), sentData.path("direction"))
assertEquals(json.textNode("RECEIVED"), receivedData.path("direction"))
assertEquals(json.textNode(recipient.nickname), sentData.path("giftInfo").path("recipientCreatorNickname"))
assertEquals(json.textNode(fixture.sender.nickname), receivedData.path("giftInfo").path("senderNickname"))
assertEquals(
if (erased) json.nullNode() else json.textNode("CJ대한통운_1234567890"),
sentData.path("giftInfo").path("tracking")
)
originalTimeline = originalTimeline ?: sentData.path("statusTimeline")
assertTrue(requireNotNull(originalTimeline).isArray)
assertEquals(5, requireNotNull(originalTimeline).size())
assertEquals(json.textNode("2026-10-10T12:00:00Z"), requireNotNull(originalTimeline).last().path("occurredAt"))
listOf(sentData, receivedData).forEach { data ->
assertEquals(originalTimeline, data.path("statusTimeline"))
assertEquals(json.booleanNode(false), data.path("trackingRequired"))
assertEquals(json.booleanNode(false), data.path("recipientAddressRequired"))
assertEquals(json.nullNode(), data.path("mailbox"))
}
listOf(sentData, receivedData, adminData).forEach { data ->
assertEquals(json.textNode(APPLICATION_NO), data.path("applicationNo"))
assertEquals(json.textNode("DELIVERED"), data.path("status"))
}
listOf("senderInfo" to senderAddress, "recipientInfo" to recipientAddress).forEach { (field, address) ->
val nickname = if (field == "senderInfo") fixture.sender.nickname else recipient.nickname
assertEquals(
(if (erased) emptyAddress else address).deepCopy<ObjectNode>().put("nickname", nickname),
adminData.path(field)
)
}
val adminList = giftHttp(HttpMethod.GET, adminListPath, adminToken)
assertEquals(json.booleanNode(true), adminList.path("success"))
assertEquals(json.numberNode(1), adminList.path("data").path("totalCount"))
val adminItem = adminList.path("data").path("items").single()
assertEquals(json.textNode(APPLICATION_NO), adminItem.path("applicationNo"))
assertEquals(json.textNode("DELIVERED"), adminItem.path("status"))
assertEquals(json.textNode(fixture.sender.nickname), adminItem.path("senderNickname"))
assertEquals(json.textNode(recipient.nickname), adminItem.path("recipientNickname"))
listOf(adminData.path("inboundDeliveryInfo"), adminItem).forEach { data ->
assertEquals(if (erased) json.nullNode() else json.textNode("CJ대한통운"), data.path("courierCompanyName"))
assertEquals(if (erased) json.nullNode() else json.textNode("1234567890"), data.path("trackingNumber"))
}
listOf(adminData, adminItem).forEach { assertEquals(json.arrayNode(), it.path("availableActions")) }
listOf(Triple("SENT", senderToken, recipient.nickname), Triple("RECEIVED", recipientToken, fixture.sender.nickname))
.forEach { (direction, token, nickname) ->
val list = giftHttp(HttpMethod.GET, "/api/v2/gifts?type=$direction", token)
assertEquals(json.booleanNode(true), list.path("success"))
assertEquals(json.numberNode(1), list.path("data").path("totalCount"))
val item = list.path("data").path("items").single()
assertEquals(json.textNode(APPLICATION_NO), item.path("applicationNo"))
assertEquals(json.textNode("DELIVERED"), item.path("status"))
assertEquals(json.textNode(direction), item.path("direction"))
assertEquals(json.textNode(nickname), item.path("counterpartNickname"))
assertEquals(json.booleanNode(!erased), item.path("recipientAddressRegistered"))
}
println(
"P15_HTTP erased=$erased status=200 success=true " +
"senderStringsEmpty=${sentData.path("senderInfo").all { it.isTextual && it.textValue().isEmpty() }} " +
"recipientAddressNull=${receivedData.path("recipientAddress").isNull} " +
"trackingNull=${sentData.path("giftInfo").path("tracking").isNull} " +
"requiredFlags=false nicknamePreserved=true timelinePreserved=true lists=1/1/1 auth=401/denied"
)
}
transaction.executeWithoutResult {
entityManager.clear()
assertEquals(1L, giftRepository.count())
assertEquals(1L, giftDeliveryRepository.count())
assertEquals(originalGift, objectMapper.valueToTree<JsonNode>(giftRepository.findById(giftId).orElseThrow()))
val reloadedDelivery = objectMapper.valueToTree<ObjectNode>(giftDeliveryRepository.findByGiftId(giftId))
personalFields.forEach { assertEquals(json.nullNode(), reloadedDelivery.path(it), it) }
reloadedDelivery.remove(personalFields + "updatedAt")
assertEquals(originalDelivery, reloadedDelivery)
assertEquals(
originalReview,
objectMapper.valueToTree<JsonNode>(entityManager.find(GiftReview::class.java, review.id))
)
val funding = chargeRepository.findById(fixture.fundingId).orElseThrow()
assertEquals(PaymentStatus.COMPLETE, funding.payment?.status)
assertEquals(PaymentGateway.PG, funding.payment?.paymentGateway)
}
assertRefundState(fixture, refunded = false)
Mockito.verify(canPaymentService, Mockito.never())
.refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId)
Mockito.verifyNoInteractions(giftPushService)
println(
"P15_DB erased=1 repeated=0 persistedNulls=12 giftRows=1 deliveryRows=1 paymentConsentReviewHistoryPreserved=true"
)
}
@Test
@DisplayName("관리자는 카테고리를 등록, 조회, 수정, 논리삭제한다")
fun shouldManageGiftCategories() {
@@ -614,6 +888,66 @@ class AdminGiftControllerTest @Autowired constructor(
Mockito.verifyNoInteractions(giftPushService)
}
@Test
@DisplayName("자동 취소 배치 조회는 ID 순서로 모든 후보를 잠그고 두 번째 선물 취소도 커밋까지 대기시킨다")
fun shouldLockExpiredTrackingBatchUntilTransactionCommits() {
val (firstGift, _) = saveAdminGift(
applicationNo = "A-BATCH-FIRST",
status = GiftStatus.RECEIVED,
senderNickname = "batch-first-sender",
recipientNickname = "batch-first-recipient",
withTracking = false
)
val firstCategory = categoryRepository.findById(firstGift.categoryId).orElseThrow()
firstCategory.classificationNumber = "101"
categoryRepository.saveAndFlush(firstCategory)
val fixture = saveFundedGift()
val winnerPaused = CountDownLatch(1)
val releaseWinner = CountDownLatch(1)
val contenderStarted = CountDownLatch(1)
val executor = Executors.newFixedThreadPool(2)
val winner = executor.submit<List<Long>> {
requireNotNull(
TransactionTemplate(transactionManager).execute {
val ids = giftRepository.findReceivedGiftsWithTrackingDeadlineBefore(NOW).map { requireNotNull(it.id) }
assertEquals(listOf(firstGift.id, fixture.gift.id), ids)
winnerPaused.countDown()
assertTrue(releaseWinner.await(10, TimeUnit.SECONDS))
ids
}
)
}
var contender: java.util.concurrent.Future<String>? = null
try {
assertTrue(winnerPaused.await(10, TimeUnit.SECONDS))
contender = executor.submit<String> {
contenderStarted.countDown()
executeRaceOperation(RaceOperation.USER, fixture)
}
assertTrue(contenderStarted.await(10, TimeUnit.SECONDS))
val waitingContender = contender
assertThrows(TimeoutException::class.java) { waitingContender.get(300, TimeUnit.MILLISECONDS) }
releaseWinner.countDown()
assertEquals(listOf(firstGift.id, fixture.gift.id), winner.get(10, TimeUnit.SECONDS))
assertEquals("CANCELED", contender.get(10, TimeUnit.SECONDS))
assertEquals(GiftStatus.CANCELED, giftRepository.findById(requireNotNull(fixture.gift.id)).orElseThrow().status)
assertTrue(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt != null)
assertRefundState(fixture, refunded = true)
assertEquals("REJECTED", executeRaceOperation(RaceOperation.USER, fixture))
assertRefundState(fixture, refunded = true)
println("P15_BATCH_LOCK orderedCandidates=2 contenderBlockedUntilCommit=true refundCan=80 refundCount=1")
} finally {
releaseWinner.countDown()
executor.shutdown()
if (!executor.awaitTermination(15, TimeUnit.SECONDS)) {
executor.shutdownNow()
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
}
if (!winner.isCancelled) winner.get(1, TimeUnit.SECONDS)
contender?.let { if (!it.isCancelled) it.get(1, TimeUnit.SECONDS) }
}
}
@ParameterizedTest
@MethodSource("cancellationRaces")
@DisplayName("관리자 취소와 동일 선물 변경은 별도 트랜잭션에서 선행 작업 커밋까지 직렬화된다")
@@ -695,6 +1029,167 @@ class AdminGiftControllerTest @Autowired constructor(
}
}
@ParameterizedTest
@MethodSource("recipientMutationRaces")
@DisplayName("배송지 등록과 예약·운영 변경은 오래된 영속 엔티티가 있어도 커밋까지 직렬화된다")
fun shouldSerializeRecipientMutations(operations: Pair<RecipientMutation, RecipientMutation>) {
val fixture = saveFundedGift()
val giftId = requireNotNull(fixture.gift.id)
val expiring = RecipientMutation.EXPIRY in listOf(operations.first, operations.second)
val initialStatus = when {
RecipientMutation.INSPECT in listOf(operations.first, operations.second) -> GiftStatus.ARRIVED_AT_MAILBOX
listOf(operations.first, operations.second).any {
it in listOf(RecipientMutation.ADMIN_DELIVERY, RecipientMutation.CONFIRM)
} -> GiftStatus.INSPECTION_COMPLETED
else -> GiftStatus.TRACKING_REGISTERED
}
TransactionTemplate(transactionManager).executeWithoutResult {
giftRepository.findById(giftId).orElseThrow().status = initialStatus
requireNotNull(giftDeliveryRepository.findByGiftId(giftId)).recipientAddressDeadlineAt =
if (expiring) NOW.minusSeconds(1) else NOW.plusHours(1)
}
val winnerPaused = CountDownLatch(1)
val releaseWinner = CountDownLatch(1)
val contenderStarted = CountDownLatch(1)
val executor = Executors.newFixedThreadPool(2)
val winner = executor.submit<String> {
requireNotNull(
TransactionTemplate(transactionManager).execute {
val result = executeRecipientMutation(operations.first, fixture)
winnerPaused.countDown()
assertTrue(releaseWinner.await(10, TimeUnit.SECONDS))
result
}
)
}
var contender: java.util.concurrent.Future<String>? = null
try {
assertTrue(winnerPaused.await(10, TimeUnit.SECONDS))
contender = executor.submit<String> {
try {
requireNotNull(
TransactionTemplate(transactionManager).execute {
assertEquals(initialStatus, giftRepository.findById(giftId).orElseThrow().status)
assertEquals(null, requireNotNull(giftDeliveryRepository.findByGiftId(giftId)).recipientAddress)
contenderStarted.countDown()
executeRecipientMutation(operations.second, fixture)
}
)
} catch (exception: SodaException) {
assertEquals("common.error.invalid_request", exception.messageKey)
"REJECTED"
}
}
assertTrue(contenderStarted.await(10, TimeUnit.SECONDS))
val waitingContender = contender
val blockedUntilCommit = try {
waitingContender.get(300, TimeUnit.MILLISECONDS)
false
} catch (exception: TimeoutException) {
true
}
releaseWinner.countDown()
val firstResult = winner.get(10, TimeUnit.SECONDS)
val secondResult = contender.get(10, TimeUnit.SECONDS)
val addressRegistered = operations.first == RecipientMutation.ADDRESS ||
(operations.second == RecipientMutation.ADDRESS && operations.first != RecipientMutation.EXPIRY)
TransactionTemplate(transactionManager).executeWithoutResult {
entityManager.clear()
val gift = giftRepository.findById(giftId).orElseThrow()
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(giftId))
assertEquals(
if (addressRegistered) {
listOf("recipient", "01098765432", "04524", "registered address", "202")
} else {
List(5) { null }
},
listOf(
delivery.recipientName,
delivery.recipientPhoneNumber,
delivery.recipientZipCode,
delivery.recipientAddress,
delivery.recipientAddressDetail
),
"committed recipient fields: $operations"
)
assertEquals(addressRegistered, gift.recipientTermsAgreed)
assertEquals(addressRegistered, gift.recipientPrivacyAgreed)
assertEquals(addressRegistered, gift.recipientTermsAgreedAt != null)
assertEquals(addressRegistered, gift.recipientPrivacyAgreedAt != null)
assertEquals(gift.recipientTermsAgreedAt, gift.recipientPrivacyAgreedAt)
val expectedStatus = when {
operations.first == RecipientMutation.EXPIRY -> GiftStatus.UNDELIVERABLE
RecipientMutation.ARRIVE in listOf(operations.first, operations.second) -> GiftStatus.ARRIVED_AT_MAILBOX
RecipientMutation.INSPECT in listOf(operations.first, operations.second) -> GiftStatus.INSPECTION_COMPLETED
RecipientMutation.UNDELIVERABLE in listOf(operations.first, operations.second) -> GiftStatus.UNDELIVERABLE
listOf(operations.first, operations.second).any {
it in listOf(RecipientMutation.ADMIN_DELIVERY, RecipientMutation.CONFIRM)
} -> GiftStatus.DELIVERED
else -> initialStatus
}
assertEquals(expectedStatus, gift.status)
assertEquals(operations.first == RecipientMutation.EXPIRY, delivery.undeliverableAt != null && expiring)
assertEquals(
operations.first == RecipientMutation.REMINDER,
delivery.recipientAddressDeadlineReminderSentAt != null
)
assertEquals(expectedStatus == GiftStatus.DELIVERED, delivery.deliveredAt != null)
assertEquals(
RecipientMutation.ARRIVE in listOf(operations.first, operations.second),
delivery.arrivedAtMailboxAt != null
)
assertEquals(
RecipientMutation.INSPECT in listOf(operations.first, operations.second),
delivery.inspectionCompletedAt != null
)
}
assertTrue(blockedUntilCommit, "contender must wait for winner commit: $operations")
if (operations.first == RecipientMutation.ADDRESS && operations.second in listOf(
RecipientMutation.REMINDER,
RecipientMutation.EXPIRY
)
) {
assertEquals("0", secondResult)
}
if (operations.first == RecipientMutation.EXPIRY) assertEquals("REJECTED", secondResult)
if (operations.first == RecipientMutation.REMINDER) assertEquals("1", firstResult)
assertEquals(0, scheduler.sendDeadlineReminderGifts(NOW))
assertEquals(0, scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW))
Mockito.verify(giftPushService, Mockito.times(if (operations.first == RecipientMutation.REMINDER) 1 else 0))
.sendRecipientAddressDeadlineReminder(APPLICATION_NO, fixture.gift.recipientMemberId)
assertRefundState(fixture, refunded = false)
println(
"P15_R1_RACE $operations first=$firstResult second=$secondResult serialized=$blockedUntilCommit persisted=true"
)
} finally {
releaseWinner.countDown()
executor.shutdown()
if (!executor.awaitTermination(15, TimeUnit.SECONDS)) {
executor.shutdownNow()
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
}
if (!winner.isCancelled) winner.get(1, TimeUnit.SECONDS)
contender?.let { if (!it.isCancelled) it.get(1, TimeUnit.SECONDS) }
}
}
private fun executeRecipientMutation(operation: RecipientMutation, fixture: FundedGift): String = when (operation) {
RecipientMutation.ADDRESS -> commandService.registerRecipientAddress(
fixture.admin,
APPLICATION_NO,
GiftRecipientAddressRegistrationCommand("recipient", "01098765432", "04524", "registered address", "202", true, true)
).status.name
RecipientMutation.REMINDER -> scheduler.sendDeadlineReminderGifts(NOW).toString()
RecipientMutation.EXPIRY -> scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW).toString()
RecipientMutation.ARRIVE -> adminService.arriveMailbox(APPLICATION_NO, NOW).status.name
RecipientMutation.INSPECT -> adminService.completeInspection(APPLICATION_NO, NOW).status.name
RecipientMutation.UNDELIVERABLE -> adminService.markUndeliverable(APPLICATION_NO, "operator reason", NOW).status.name
RecipientMutation.ADMIN_DELIVERY -> adminService.completeDelivery(APPLICATION_NO, NOW).status.name
RecipientMutation.CONFIRM -> commandService.confirmDelivery(fixture.admin, APPLICATION_NO).status.name
}
enum class RecipientMutation { ADDRESS, REMINDER, EXPIRY, ARRIVE, INSPECT, UNDELIVERABLE, ADMIN_DELIVERY, CONFIRM }
private fun executeRaceOperation(operation: RaceOperation, fixture: FundedGift): String {
val requestAttributes = ServletRequestAttributes(MockHttpServletRequest())
RequestContextHolder.setRequestAttributes(requestAttributes)
@@ -772,7 +1267,7 @@ class AdminGiftControllerTest @Autowired constructor(
return body
}
private fun saveFundedGift(): FundedGift = requireNotNull(
private fun saveFundedGift(recipient: Member? = null): FundedGift = requireNotNull(
TransactionTemplate(transactionManager).execute {
val sender = memberRepository.save(
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "funded-sender").apply {
@@ -811,7 +1306,7 @@ class AdminGiftControllerTest @Autowired constructor(
Gift(
applicationNo = APPLICATION_NO,
senderMemberId = requireNotNull(sender.id),
recipientMemberId = requireNotNull(admin.id),
recipientMemberId = requireNotNull((recipient ?: admin).id),
status = GiftStatus.RECEIVED,
sizeCode = GiftSize.SMALL,
categoryId = requireNotNull(category.id),
@@ -971,6 +1466,28 @@ class AdminGiftControllerTest @Autowired constructor(
}
private companion object {
@JvmStatic
fun recipientMutationRaces() = listOf(
RecipientMutation.REMINDER to RecipientMutation.ADDRESS,
RecipientMutation.ADDRESS to RecipientMutation.REMINDER,
RecipientMutation.EXPIRY to RecipientMutation.ADDRESS,
RecipientMutation.ADDRESS to RecipientMutation.EXPIRY,
RecipientMutation.ARRIVE to RecipientMutation.ADDRESS,
RecipientMutation.ADDRESS to RecipientMutation.ARRIVE,
RecipientMutation.INSPECT to RecipientMutation.ADDRESS,
RecipientMutation.ADDRESS to RecipientMutation.INSPECT,
RecipientMutation.ADDRESS to RecipientMutation.UNDELIVERABLE,
RecipientMutation.ADDRESS to RecipientMutation.ADMIN_DELIVERY,
RecipientMutation.ADDRESS to RecipientMutation.CONFIRM,
RecipientMutation.REMINDER to RecipientMutation.CONFIRM,
RecipientMutation.CONFIRM to RecipientMutation.REMINDER,
RecipientMutation.EXPIRY to RecipientMutation.CONFIRM,
RecipientMutation.CONFIRM to RecipientMutation.EXPIRY,
RecipientMutation.REMINDER to RecipientMutation.UNDELIVERABLE,
RecipientMutation.UNDELIVERABLE to RecipientMutation.REMINDER,
RecipientMutation.REMINDER to RecipientMutation.REMINDER
)
@JvmStatic
fun cancellationRaces() = listOf(
RaceOperation.ADMIN to RaceOperation.ADMIN,
@@ -547,7 +547,7 @@ class GiftCommandServiceTest {
trackingDeadlineAt = NOW.plusDays(3)
)
Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(cancellationGift)
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(cancellationDelivery)
Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn(cancellationDelivery)
}
fun prepareGiftForTracking(
@@ -580,7 +580,7 @@ class GiftCommandServiceTest {
trackingDeadlineAt = NOW.plusDays(3)
)
Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(trackingGift)
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn(
trackingDelivery.takeIf { deliveryExists }
)
}
@@ -614,8 +614,8 @@ class GiftCommandServiceTest {
senderAddress = "서울시 강남구",
trackingDeadlineAt = NOW.plusDays(3)
)
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(recipientAddressGift)
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(recipientAddressGift)
Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn(
recipientAddressDelivery.takeIf { deliveryExists }
)
}
@@ -649,8 +649,8 @@ class GiftCommandServiceTest {
senderAddress = "서울시 강남구",
trackingDeadlineAt = NOW.plusDays(3)
)
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(deliveryConfirmationGift)
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(deliveryConfirmationGift)
Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn(
deliveryConfirmationDelivery.takeIf { deliveryExists }
)
}
@@ -1,23 +1,43 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.mockito.Mockito
import org.redisson.api.RLock
import org.redisson.api.RedissonClient
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
import org.springframework.context.annotation.Import
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.test.context.transaction.TestTransaction
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
@DataJpaTest(
properties = [
@@ -29,16 +49,296 @@ import java.time.LocalDateTime
@DisplayName("선물 자동처리 스케줄러 테스트")
class GiftSchedulerTest @Autowired constructor(
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository
private val giftDeliveryRepository: GiftDeliveryRepository,
private val entityManager: TestEntityManager
) {
private val canPaymentService = Mockito.mock(CanPaymentService::class.java)
private val giftPushService = Mockito.mock(GiftPushService::class.java)
private val redissonClient = Mockito.mock(RedissonClient::class.java)
private val scheduler = GiftScheduler(
giftRepository,
giftDeliveryRepository,
canPaymentService,
giftPushService,
Mockito.mock(RedissonClient::class.java)
redissonClient
)
@ParameterizedTest
@MethodSource("scheduledEntryPoints")
@DisplayName("신규 진입점은 각 KST 분의 parameterless transactional 스케줄로 등록된다")
fun shouldScheduleEachEntryPointAtItsKstMinute(entryPoint: ScheduledEntryPoint) {
val method = GiftScheduler::class.java.getDeclaredMethod(entryPoint.methodName)
val scheduled = requireNotNull(method.getAnnotation(Scheduled::class.java))
assertEquals(entryPoint.cron, scheduled.cron)
assertEquals("Asia/Seoul", scheduled.zone)
assertNotNull(method.getAnnotation(Transactional::class.java))
}
@ParameterizedTest
@MethodSource("scheduledEntryPoints")
@DisplayName("각 신규 진입점은 독립 lock을 획득하면 현재 시각으로 한 번 위임하고 해제한다")
fun shouldAcquireDistinctLockAndDelegateWithWallClockNow(entryPoint: ScheduledEntryPoint) {
val lock = schedulerLock(entryPoint, acquired = true, owned = true)
val schedulerSpy = Mockito.spy(scheduler)
val before = LocalDateTime.now()
entryPoint.run(schedulerSpy)
assertWallClockDelegation(schedulerSpy, entryPoint, before, LocalDateTime.now())
TestTransaction.end()
Mockito.verify(redissonClient).getLock(entryPoint.lockKey)
Mockito.verifyNoMoreInteractions(redissonClient)
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.verify(lock).isHeldByCurrentThread
Mockito.verify(lock).unlock()
Mockito.verifyNoInteractions(canPaymentService, giftPushService)
}
@ParameterizedTest
@MethodSource("scheduledEntryPoints")
@DisplayName("각 신규 진입점은 lock 획득 실패 시 업무 위임과 unlock을 하지 않는다")
fun shouldSkipEachDelegateWhenItsLockIsNotAcquired(entryPoint: ScheduledEntryPoint) {
val lock = schedulerLock(entryPoint, acquired = false, owned = false)
val schedulerSpy = Mockito.spy(scheduler)
entryPoint.run(schedulerSpy)
assertEquals(listOf(entryPoint.methodName), Mockito.mockingDetails(schedulerSpy).invocations.map { it.method.name })
Mockito.verify(redissonClient).getLock(entryPoint.lockKey)
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.verify(lock, Mockito.never()).isHeldByCurrentThread
Mockito.verify(lock, Mockito.never()).unlock()
Mockito.verifyNoInteractions(canPaymentService, giftPushService)
}
@ParameterizedTest
@MethodSource("scheduledEntryPoints")
@DisplayName("각 신규 진입점은 업무 메서드 예외를 전파하면서 소유한 lock을 해제한다")
fun shouldUnlockEachOwnedLockWhenDelegateThrows(entryPoint: ScheduledEntryPoint) {
TestTransaction.end()
val lock = schedulerLock(entryPoint, acquired = true, owned = true)
val failure = IllegalStateException("repository unavailable")
val failingScheduler = GiftScheduler(
Mockito.mock(GiftRepository::class.java) { throw failure },
giftDeliveryRepository,
canPaymentService,
giftPushService,
redissonClient
)
val exception = assertThrows(IllegalStateException::class.java) { entryPoint.run(failingScheduler) }
assertSame(failure, exception)
Mockito.verify(redissonClient).getLock(entryPoint.lockKey)
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.verify(lock).isHeldByCurrentThread
Mockito.verify(lock).unlock()
Mockito.verifyNoInteractions(canPaymentService, giftPushService)
}
@ParameterizedTest
@MethodSource("scheduledEntryPoints")
@DisplayName("각 신규 진입점은 업무 실행 후 현재 스레드가 소유하지 않은 lock을 해제하지 않는다")
fun shouldNotUnlockEachLockWhenCurrentThreadDoesNotOwnIt(entryPoint: ScheduledEntryPoint) {
val lock = schedulerLock(entryPoint, acquired = true, owned = false)
val schedulerSpy = Mockito.spy(scheduler)
val before = LocalDateTime.now()
entryPoint.run(schedulerSpy)
assertWallClockDelegation(schedulerSpy, entryPoint, before, LocalDateTime.now())
TestTransaction.end()
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.verify(lock).isHeldByCurrentThread
Mockito.verify(lock, Mockito.never()).unlock()
}
@ParameterizedTest
@MethodSource("scheduledEntryPoints")
@DisplayName("각 신규 lock은 실제 commit 직전과 직후에도 재진입을 막고 완료 후 해제된다")
fun shouldHoldEachLockThroughCommitAndAllowReentryAfterCompletion(entryPoint: ScheduledEntryPoint) {
val (lock, contender) = contendingLocks(entryPoint)
var beforeCommitCalled = false
var afterCommitCalled = false
var completionStatus: Int? = null
entryPoint.run(scheduler)
TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization {
override fun beforeCommit(readOnly: Boolean) {
beforeCommitCalled = true
Mockito.verify(lock, Mockito.never()).unlock()
assertFalse(contender.tryLock(0, -1, TimeUnit.SECONDS))
}
override fun afterCommit() {
afterCommitCalled = true
Mockito.verify(lock, Mockito.never()).unlock()
assertFalse(contender.tryLock(0, -1, TimeUnit.SECONDS))
}
override fun afterCompletion(status: Int) {
completionStatus = status
}
})
TestTransaction.flagForCommit()
TestTransaction.end()
assertTrue(beforeCommitCalled)
assertTrue(afterCommitCalled)
assertEquals(TransactionSynchronization.STATUS_COMMITTED, completionStatus)
Mockito.verify(lock).unlock()
assertTrue(contender.tryLock(0, -1, TimeUnit.SECONDS))
contender.unlock()
assertEquals(0L, giftRepository.count())
assertEquals(0L, giftDeliveryRepository.count())
}
@ParameterizedTest
@MethodSource("scheduledEntryPoints")
@DisplayName("각 신규 lock은 업무 예외 후 실제 rollback 직전까지 재진입을 막고 완료 후 해제된다")
fun shouldHoldEachLockOnDelegateFailureUntilRollback(entryPoint: ScheduledEntryPoint) {
val (lock, contender) = contendingLocks(entryPoint)
val failure = IllegalStateException("repository unavailable")
val failingScheduler = GiftScheduler(
Mockito.mock(GiftRepository::class.java) { throw failure },
giftDeliveryRepository,
canPaymentService,
giftPushService,
redissonClient
)
var heldBeforeRollback = false
var reenteredBeforeRollback = true
var completionStatus: Int? = null
val exception = assertThrows(IllegalStateException::class.java) { entryPoint.run(failingScheduler) }
TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization {
override fun beforeCompletion() {
heldBeforeRollback = lock.isHeldByCurrentThread
reenteredBeforeRollback = contender.tryLock(0, -1, TimeUnit.SECONDS)
}
override fun afterCompletion(status: Int) {
completionStatus = status
}
})
TestTransaction.flagForRollback()
TestTransaction.end()
assertSame(failure, exception)
assertTrue(heldBeforeRollback)
assertFalse(reenteredBeforeRollback)
assertEquals(TransactionSynchronization.STATUS_ROLLED_BACK, completionStatus)
Mockito.verify(lock).unlock()
assertTrue(contender.tryLock(0, -1, TimeUnit.SECONDS))
contender.unlock()
}
@ParameterizedTest
@MethodSource("scheduledEntryPoints")
@DisplayName("신규 lock 소유권은 등록 시점이 아닌 transaction 완료 시점에 확인한다")
fun shouldCheckEachLockOwnershipAtTransactionCompletion(entryPoint: ScheduledEntryPoint) {
val lock = schedulerLock(entryPoint, acquired = true, owned = true)
entryPoint.run(scheduler)
Mockito.verify(lock, Mockito.never()).isHeldByCurrentThread
Mockito.verify(lock, Mockito.never()).unlock()
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
TestTransaction.end()
Mockito.verify(lock).isHeldByCurrentThread
Mockito.verify(lock, Mockito.never()).unlock()
}
@ParameterizedTest
@MethodSource("scheduledEntryPoints")
@DisplayName("lock 획득 실패는 소유 여부와 무관하게 해제 callback을 등록하지 않는다")
fun shouldNotRegisterReleaseForEachUnacquiredLock(entryPoint: ScheduledEntryPoint) {
val lock = schedulerLock(entryPoint, acquired = false, owned = true)
val synchronizations = TransactionSynchronizationManager.getSynchronizations()
entryPoint.run(scheduler)
assertEquals(synchronizations, TransactionSynchronizationManager.getSynchronizations())
TestTransaction.end()
Mockito.verify(lock, Mockito.never()).isHeldByCurrentThread
Mockito.verify(lock, Mockito.never()).unlock()
}
@ParameterizedTest
@MethodSource("scheduledEntryPoints")
@DisplayName("transaction 없는 직접 호출은 업무 실행을 마친 뒤에만 lock을 즉시 해제한다")
fun shouldReleaseEachLockAfterDirectInvocationWithoutTransaction(entryPoint: ScheduledEntryPoint) {
TestTransaction.end()
assertFalse(TransactionSynchronizationManager.isSynchronizationActive())
val lock = schedulerLock(entryPoint, acquired = true, owned = true)
val directScheduler = Mockito.spy(
GiftScheduler(
Mockito.mock(GiftRepository::class.java),
Mockito.mock(GiftDeliveryRepository::class.java),
canPaymentService,
giftPushService,
redissonClient
)
)
val before = LocalDateTime.now()
entryPoint.run(directScheduler)
assertWallClockDelegation(directScheduler, entryPoint, before, LocalDateTime.now())
val delegateCall = Mockito.mockingDetails(directScheduler).invocations.single {
it.method.name == entryPoint.delegateName
}
val unlockCall = Mockito.mockingDetails(lock).invocations.single { it.method.name == "unlock" }
assertTrue(delegateCall.sequenceNumber < unlockCall.sequenceNumber)
}
private fun contendingLocks(entryPoint: ScheduledEntryPoint): Pair<RLock, RLock> {
val lock = Mockito.mock(RLock::class.java)
val contender = Mockito.mock(RLock::class.java)
val heldBy = AtomicReference<RLock?>()
listOf(lock, contender).forEach { candidate ->
Mockito.`when`(candidate.tryLock(0, -1, TimeUnit.SECONDS)).thenAnswer { heldBy.compareAndSet(null, candidate) }
Mockito.`when`(candidate.isHeldByCurrentThread).thenAnswer { heldBy.get() === candidate }
Mockito.doAnswer {
assertTrue(heldBy.compareAndSet(candidate, null))
null
}.`when`(candidate).unlock()
}
Mockito.`when`(redissonClient.getLock(entryPoint.lockKey)).thenReturn(lock)
return lock to contender
}
private fun schedulerLock(entryPoint: ScheduledEntryPoint, acquired: Boolean, owned: Boolean): RLock {
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(entryPoint.lockKey)).thenReturn(lock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(acquired)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(owned)
return lock
}
private fun assertWallClockDelegation(
schedulerSpy: GiftScheduler,
entryPoint: ScheduledEntryPoint,
before: LocalDateTime,
after: LocalDateTime
) {
val calls = Mockito.mockingDetails(schedulerSpy).invocations.filter {
it.method.parameterTypes.contentEquals(arrayOf(LocalDateTime::class.java))
}
assertEquals(listOf(entryPoint.delegateName), calls.map { it.method.name })
val now = calls.single().arguments.single() as LocalDateTime
assertTrue(!now.isBefore(before) && !now.isAfter(after))
}
data class ScheduledEntryPoint(
val methodName: String,
val delegateName: String,
val cron: String,
val lockKey: String,
val run: (GiftScheduler) -> Unit
)
@Test
@@ -299,24 +599,292 @@ class GiftSchedulerTest @Autowired constructor(
Mockito.verifyNoInteractions(giftPushService)
}
@Test
@DisplayName("전달 완료 후 정확히 7일인 선물의 배송 개인정보 12개를 DB에서 지운다")
fun shouldEraseDeliveredGiftWhenDeliveredAtEqualsSevenDayCutoff() {
val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF)
flushAndClear()
val eligibleGifts = giftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(ERASURE_CUTOFF)
assertEquals(listOf(gift.id), eligibleGifts.map { it.id })
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
flushAndClear()
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
}
@Test
@DisplayName("전달 완료 후 7일보다 1초 덜 지난 선물의 개인정보는 보존한다")
fun shouldIgnoreDeliveredGiftOneSecondNewerThanSevenDayCutoff() {
val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF.plusSeconds(1))
val originalPersonalData = personalData(delivery)
flushAndClear()
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
flushAndClear()
assertEquals(originalPersonalData, personalData(reloadDelivery(gift)))
}
@Test
@DisplayName("도입 시점보다 오래전에 생성되고 전달 완료된 과거 누적 선물도 지운다")
fun shouldEraseOlderHistoricalDeliveredGifts() {
val historicalAt = LocalDateTime.of(2020, 1, 1, 12, 0)
val (gift, delivery) = saveGiftForErasure(historicalAt.plusDays(10))
gift.createdAt = historicalAt
delivery.createdAt = historicalAt
flushAndClear()
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
flushAndClear()
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
assertEquals(historicalAt, reloadDelivery(gift).createdAt)
assertEquals(historicalAt, entityManager.find(Gift::class.java, gift.id).createdAt)
}
@Test
@DisplayName("완료 시각이 없거나 전달 완료가 아닌 모든 상태는 개인정보를 보존한다")
fun shouldIgnoreNullDeliveredAtAndNonDeliveredGifts() {
val gifts = listOf(saveGiftForErasure(null)) + GiftStatus.values()
.filter { it != GiftStatus.DELIVERED }
.mapIndexed { index, status ->
saveGiftForErasure(ERASURE_CUTOFF.minusDays(30), status, "IGNORE-$index")
}
val originals = gifts.map { (_, delivery) -> personalData(delivery) }
flushAndClear()
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
flushAndClear()
gifts.forEachIndexed { index, (gift, _) ->
assertEquals(gift.status, entityManager.find(Gift::class.java, gift.id).status)
assertEquals(originals[index], personalData(reloadDelivery(gift)))
}
}
@Test
@DisplayName("12개 개인정보 중 어느 하나만 남아 있어도 나머지 값을 DB에서 지운다")
fun shouldEraseRemainingFieldsFromPartiallyClearedGift() {
val gifts = PERSONAL_DATA_FIELDS.mapIndexed { index, remainingField ->
saveGiftForErasure(ERASURE_CUTOFF, applicationNo = "PARTIAL-$index").also { (_, delivery) ->
PERSONAL_DATA_FIELDS.filter { it != remainingField }.forEach { it.set(delivery, null) }
}
}
flushAndClear()
assertEquals(12, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
flushAndClear()
gifts.forEach { (gift, _) -> assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) }
}
@Test
@DisplayName("이미 개인정보가 모두 null인 선물은 첫 실행부터 변경하지 않는다")
fun shouldReturnZeroWhenAllPersonalDataIsAlreadyNull() {
val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF)
PERSONAL_DATA_FIELDS.forEach { it.set(delivery, null) }
flushAndClear()
val updatedAt = reloadDelivery(gift).updatedAt
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
flushAndClear()
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
assertEquals(updatedAt, reloadDelivery(gift).updatedAt)
}
@Test
@DisplayName("개인정보 정리를 재실행하면 추가 변경 없이 0건을 반환한다")
fun shouldReturnZeroWhenErasureRunsAgain() {
val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF)
flushAndClear()
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
flushAndClear()
val updatedAt = reloadDelivery(gift).updatedAt
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW.plusDays(1)))
flushAndClear()
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
assertEquals(updatedAt, reloadDelivery(gift).updatedAt)
}
@Test
@DisplayName("개인정보 정리는 선물·회원·결제·동의·리뷰와 업무 시각을 DB에 보존하고 환불·푸시하지 않는다")
fun shouldPreserveGiftMemberPaymentConsentReviewAndBusinessTimestamps() {
val sender = entityManager.persist(
Member(password = "test-password", nickname = "sender", profileImage = "sender-profile.png")
.apply { pgChargeCan = 120 }
)
val recipient = entityManager.persist(
Member(password = "test-password", nickname = "recipient", profileImage = "recipient-profile.png")
)
val payment = entityManager.persist(
UseCan(canUsage = CanUsage.GIFT, can = 60, rewardCan = 20).apply { member = sender }
)
val (gift, delivery) = saveGift(
GiftStatus.DELIVERED,
APPLIED_AT.plusDays(3),
senderMemberId = requireNotNull(sender.id),
recipientMemberId = requireNotNull(recipient.id),
canUsageId = requireNotNull(payment.id)
)
fillDeliveryPersonalData(delivery)
gift.recipientTermsAgreed = true
gift.recipientPrivacyAgreed = true
gift.recipientTermsAgreedAt = APPLIED_AT.plusDays(2)
gift.recipientPrivacyAgreedAt = APPLIED_AT.plusDays(2)
delivery.recipientAddressDeadlineAt = APPLIED_AT.plusDays(8)
delivery.trackingDeadlineReminderSentAt = APPLIED_AT.plusDays(1)
delivery.recipientAddressDeadlineReminderSentAt = APPLIED_AT.plusDays(7)
delivery.trackingRegisteredAt = APPLIED_AT.plusDays(2)
delivery.arrivedAtMailboxAt = APPLIED_AT.plusDays(3)
delivery.inspectionCompletedAt = APPLIED_AT.plusDays(4)
delivery.deliveredAt = ERASURE_CUTOFF
delivery.canceledAt = APPLIED_AT.plusHours(1)
delivery.undeliverableAt = APPLIED_AT.plusHours(2)
delivery.undeliverableReason = "보존할 업무 사유"
val review = entityManager.persist(
GiftReview(requireNotNull(gift.id), requireNotNull(sender.id), 5, "[\"안전\"]", "감사합니다")
)
flushAndClear()
val originalGift = entityManager.find(Gift::class.java, gift.id)
val originalDelivery = reloadDelivery(gift)
val originalPayment = entityManager.find(UseCan::class.java, payment.id)
val originalReview = entityManager.find(GiftReview::class.java, review.id)
flushAndClear()
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
flushAndClear()
val reloadedGift = entityManager.find(Gift::class.java, gift.id)
assertEquals(originalGift.id, reloadedGift.id)
assertEquals(originalGift.applicationNo, reloadedGift.applicationNo)
assertEquals(originalGift.status, reloadedGift.status)
assertEquals(originalGift.senderMemberId, reloadedGift.senderMemberId)
assertEquals(originalGift.recipientMemberId, reloadedGift.recipientMemberId)
assertEquals(originalGift.sizeCode, reloadedGift.sizeCode)
assertEquals(originalGift.categoryId, reloadedGift.categoryId)
assertEquals(originalGift.categoryNameSnapshot, reloadedGift.categoryNameSnapshot)
assertEquals(originalGift.salePriceCan, reloadedGift.salePriceCan)
assertEquals(originalGift.canUsageId, reloadedGift.canUsageId)
assertEquals(originalGift.senderTermsAgreed, reloadedGift.senderTermsAgreed)
assertEquals(originalGift.senderPrivacyAgreed, reloadedGift.senderPrivacyAgreed)
assertEquals(originalGift.senderTermsAgreedAt, reloadedGift.senderTermsAgreedAt)
assertEquals(originalGift.senderPrivacyAgreedAt, reloadedGift.senderPrivacyAgreedAt)
assertEquals(originalGift.recipientTermsAgreed, reloadedGift.recipientTermsAgreed)
assertEquals(originalGift.recipientPrivacyAgreed, reloadedGift.recipientPrivacyAgreed)
assertEquals(originalGift.recipientTermsAgreedAt, reloadedGift.recipientTermsAgreedAt)
assertEquals(originalGift.recipientPrivacyAgreedAt, reloadedGift.recipientPrivacyAgreedAt)
assertEquals(originalGift.damageWaiverAgreed, reloadedGift.damageWaiverAgreed)
assertEquals(originalGift.createdAt, reloadedGift.createdAt)
val reloadedDelivery = reloadDelivery(gift)
assertEquals(List(12) { null }, personalData(reloadedDelivery))
assertEquals(originalDelivery.id, reloadedDelivery.id)
assertEquals(originalDelivery.giftId, reloadedDelivery.giftId)
assertEquals(originalDelivery.createdAt, reloadedDelivery.createdAt)
assertEquals(originalDelivery.trackingDeadlineAt, reloadedDelivery.trackingDeadlineAt)
assertEquals(originalDelivery.recipientAddressDeadlineAt, reloadedDelivery.recipientAddressDeadlineAt)
assertEquals(originalDelivery.trackingDeadlineReminderSentAt, reloadedDelivery.trackingDeadlineReminderSentAt)
assertEquals(
originalDelivery.recipientAddressDeadlineReminderSentAt,
reloadedDelivery.recipientAddressDeadlineReminderSentAt
)
assertEquals(originalDelivery.trackingRegisteredAt, reloadedDelivery.trackingRegisteredAt)
assertEquals(originalDelivery.arrivedAtMailboxAt, reloadedDelivery.arrivedAtMailboxAt)
assertEquals(originalDelivery.inspectionCompletedAt, reloadedDelivery.inspectionCompletedAt)
assertEquals(originalDelivery.deliveredAt, reloadedDelivery.deliveredAt)
assertEquals(originalDelivery.canceledAt, reloadedDelivery.canceledAt)
assertEquals(originalDelivery.undeliverableAt, reloadedDelivery.undeliverableAt)
assertEquals(originalDelivery.undeliverableReason, reloadedDelivery.undeliverableReason)
val reloadedPayment = entityManager.find(UseCan::class.java, payment.id)
assertEquals(payment.id, reloadedPayment.id)
assertEquals(sender.id, reloadedPayment.member?.id)
assertEquals(CanUsage.GIFT, reloadedPayment.canUsage)
assertEquals(60, reloadedPayment.can)
assertEquals(20, reloadedPayment.rewardCan)
assertEquals(false, reloadedPayment.isRefund)
assertEquals(originalPayment.createdAt, reloadedPayment.createdAt)
val reloadedSender = entityManager.find(Member::class.java, sender.id)
val reloadedRecipient = entityManager.find(Member::class.java, recipient.id)
assertEquals("sender", reloadedSender.nickname)
assertEquals("sender-profile.png", reloadedSender.profileImage)
assertEquals(120, reloadedSender.pgChargeCan)
assertEquals(0, reloadedSender.pgRewardCan)
assertEquals("recipient", reloadedRecipient.nickname)
assertEquals("recipient-profile.png", reloadedRecipient.profileImage)
val reloadedReview = entityManager.find(GiftReview::class.java, review.id)
assertEquals(originalReview.id, reloadedReview.id)
assertEquals(originalReview.giftId, reloadedReview.giftId)
assertEquals(originalReview.senderMemberId, reloadedReview.senderMemberId)
assertEquals(originalReview.rating, reloadedReview.rating)
assertEquals(originalReview.keywords, reloadedReview.keywords)
assertEquals(originalReview.comment, reloadedReview.comment)
assertEquals(originalReview.createdAt, reloadedReview.createdAt)
assertEquals(1L, giftRepository.count())
assertEquals(1L, giftDeliveryRepository.count())
Mockito.verifyNoInteractions(canPaymentService, giftPushService)
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
private fun reloadDelivery(gift: Gift): GiftDelivery =
requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
private fun personalData(delivery: GiftDelivery): List<String?> = PERSONAL_DATA_FIELDS.map { it.get(delivery) }
private fun saveGiftForErasure(
deliveredAt: LocalDateTime?,
status: GiftStatus = GiftStatus.DELIVERED,
applicationNo: String = APPLICATION_NO
): Pair<Gift, GiftDelivery> = saveGift(status, APPLIED_AT.plusDays(3), applicationNo = applicationNo)
.also { (_, delivery) ->
fillDeliveryPersonalData(delivery)
delivery.deliveredAt = deliveredAt
}
private fun fillDeliveryPersonalData(delivery: GiftDelivery) {
delivery.senderName = "홍길동"
delivery.senderPhoneNumber = "01012345678"
delivery.senderZipCode = "06234"
delivery.senderAddress = "서울시 강남구"
delivery.senderAddressDetail = "101호"
delivery.recipientName = "수취인"
delivery.recipientPhoneNumber = "01087654321"
delivery.recipientZipCode = "05555"
delivery.recipientAddress = "서울시 송파구"
delivery.recipientAddressDetail = "202호"
delivery.courierCompanyName = "택배사"
delivery.trackingNumber = "TRACKING-123"
}
private fun saveGift(
status: GiftStatus,
trackingDeadlineAt: LocalDateTime,
recipientAddressDeadlineAt: LocalDateTime? = null,
recipientAddress: String? = null,
applicationNo: String = APPLICATION_NO
applicationNo: String = APPLICATION_NO,
senderMemberId: Long = SENDER_MEMBER_ID,
recipientMemberId: Long = RECIPIENT_MEMBER_ID,
canUsageId: Long = CAN_USAGE_ID
): Pair<Gift, GiftDelivery> {
val gift = giftRepository.save(
Gift(
applicationNo = applicationNo,
senderMemberId = SENDER_MEMBER_ID,
recipientMemberId = RECIPIENT_MEMBER_ID,
senderMemberId = senderMemberId,
recipientMemberId = recipientMemberId,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = CATEGORY_ID,
categoryNameSnapshot = "인형",
salePriceCan = 80,
canUsageId = CAN_USAGE_ID,
canUsageId = canUsageId,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = APPLIED_AT,
@@ -341,11 +909,52 @@ class GiftSchedulerTest @Autowired constructor(
}
private companion object {
@JvmStatic
fun scheduledEntryPoints() = listOf(
ScheduledEntryPoint(
"runExpiredRecipientAddressGiftExpiration",
"markExpiredRecipientAddressGiftsUndeliverable",
"0 10 * * * *",
"lock:gift-expired-recipient-address-undeliverable",
GiftScheduler::runExpiredRecipientAddressGiftExpiration
),
ScheduledEntryPoint(
"runDeliveredGiftPersonalDataErasure",
"eraseDeliveredGiftPersonalData",
"0 20 * * * *",
"lock:gift-delivered-personal-data-erasure",
GiftScheduler::runDeliveredGiftPersonalDataErasure
),
ScheduledEntryPoint(
"runDeadlineReminderGifts",
"sendDeadlineReminderGifts",
"0 40 * * * *",
"lock:gift-deadline-reminders",
GiftScheduler::runDeadlineReminderGifts
)
)
const val APPLICATION_NO = "G20260929000001"
const val SENDER_MEMBER_ID = 1L
const val RECIPIENT_MEMBER_ID = 2L
const val CATEGORY_ID = 3L
const val CAN_USAGE_ID = 4L
val APPLIED_AT: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
val ERASURE_NOW: LocalDateTime = LocalDateTime.of(2026, 10, 23, 12, 0)
val ERASURE_CUTOFF: LocalDateTime = LocalDateTime.of(2026, 10, 16, 12, 0)
val PERSONAL_DATA_FIELDS = listOf(
GiftDelivery::senderName,
GiftDelivery::senderPhoneNumber,
GiftDelivery::senderZipCode,
GiftDelivery::senderAddress,
GiftDelivery::senderAddressDetail,
GiftDelivery::recipientName,
GiftDelivery::recipientPhoneNumber,
GiftDelivery::recipientZipCode,
GiftDelivery::recipientAddress,
GiftDelivery::recipientAddressDetail,
GiftDelivery::courierCompanyName,
GiftDelivery::trackingNumber
)
}
}