feat(gift): 배송 개인정보 정리 스케줄과 동시 변경 보호를 추가한다

This commit is contained in:
2026-10-26 08:44:59 +09:00
parent f46a6c4c7c
commit 8b2ac38b83
8 changed files with 1360 additions and 66 deletions
@@ -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
)
}
}