Test #460
+18
-1
@@ -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) }
|
||||
}
|
||||
|
||||
+35
-7
@@ -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) }
|
||||
}
|
||||
|
||||
+151
-28
@@ -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
|
||||
|
||||
+520
-3
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
+615
-6
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user