feat(gift): 전달불가 선물의 배송 개인정보를 만료 후 파기한다

This commit is contained in:
2026-11-01 14:33:35 +09:00
parent 1a544ddd45
commit 373b11348b
4 changed files with 129 additions and 78 deletions
@@ -108,17 +108,26 @@ interface GiftRepository : JpaRepository<Gift, Long>, GiftLockRepository {
""" """
select g select g
from Gift g from Gift g
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.DELIVERED where exists (
and exists (
select d.id select d.id
from GiftDelivery d from GiftDelivery d
where d.giftId = g.id where d.giftId = g.id
and (
(
g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.DELIVERED
and d.deliveredAt is not null and d.deliveredAt is not null
and d.deliveredAt <= :cutoff and d.deliveredAt <= :cutoff
) )
or (
g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.UNDELIVERABLE
and d.undeliverableAt is not null
and d.undeliverableAt <= :cutoff
)
)
)
""" """
) )
fun findDeliveredGiftsWithDeliveryBeforeOrAt(@Param("cutoff") cutoff: LocalDateTime): List<Gift> fun findGiftsWithPersonalDataRetentionExpired(@Param("cutoff") cutoff: LocalDateTime): List<Gift>
@Query( @Query(
""" """
@@ -92,23 +92,23 @@ class GiftScheduler(
@Scheduled(cron = "0 20 * * * *", zone = "Asia/Seoul") @Scheduled(cron = "0 20 * * * *", zone = "Asia/Seoul")
@Transactional @Transactional
fun runDeliveredGiftPersonalDataErasure() { fun runGiftPersonalDataErasure() {
val lock = redissonClient.getLock(DELIVERED_GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY) val lock = redissonClient.getLock(GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY)
if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) { if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) {
return return
} }
try { try {
eraseDeliveredGiftPersonalData(LocalDateTime.now()) eraseGiftPersonalData(LocalDateTime.now())
} finally { } finally {
unlockAfterTransaction(lock) unlockAfterTransaction(lock)
} }
} }
@Transactional @Transactional
fun eraseDeliveredGiftPersonalData(now: LocalDateTime): Int { fun eraseGiftPersonalData(now: LocalDateTime): Int {
val cutoff = now.minusDays(7) val cutoff = now.minusDays(7)
val expiredGifts = giftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(cutoff) val expiredGifts = giftRepository.findGiftsWithPersonalDataRetentionExpired(cutoff)
return expiredGifts.count { gift -> return expiredGifts.count { gift ->
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
@@ -218,7 +218,7 @@ class GiftScheduler(
const val DEADLINE_REMINDER_HOURS = 24L const val DEADLINE_REMINDER_HOURS = 24L
const val EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY = "lock:gift-expired-tracking-registration-cancellation" 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 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 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 DEADLINE_REMINDER_GIFTS_LOCK_KEY = "lock:gift-deadline-reminders"
const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과" const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과"
} }
@@ -162,7 +162,7 @@ class AdminGiftControllerTest @Autowired constructor(
mapOf( mapOf(
"runExpiredTrackingRegistrationGiftCancellation" to "0 0 * * * *", "runExpiredTrackingRegistrationGiftCancellation" to "0 0 * * * *",
"runExpiredRecipientAddressGiftExpiration" to "0 10 * * * *", "runExpiredRecipientAddressGiftExpiration" to "0 10 * * * *",
"runDeliveredGiftPersonalDataErasure" to "0 20 * * * *", "runGiftPersonalDataErasure" to "0 20 * * * *",
"runDeadlineReminderGifts" to "0 40 * * * *" "runDeadlineReminderGifts" to "0 40 * * * *"
), ),
scheduledJobs.associate { scheduledJobs.associate {
@@ -311,12 +311,13 @@ class AdminGiftControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.availableActions[1]").value("MARK_UNDELIVERABLE")) .andExpect(jsonPath("$.data.availableActions[1]").value("MARK_UNDELIVERABLE"))
} }
@Test @ParameterizedTest
@DisplayName("관리자는 개인정보가 지워진 전달 완료 선물을 기존 빈 문자열과 null 응답으로 조회한다") @EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
fun shouldReturnCompatibleErasedAdminGiftResponses() { @DisplayName("관리자는 개인정보가 지워진 종료 선물을 기존 빈 문자열과 null 응답으로 조회한다")
fun shouldReturnCompatibleErasedAdminGiftResponses(giftStatus: GiftStatus) {
val (gift, delivery) = saveAdminGift( val (gift, delivery) = saveAdminGift(
applicationNo = "A-ERASED-CONTROLLER", applicationNo = "A-ERASED-CONTROLLER",
status = GiftStatus.DELIVERED, status = giftStatus,
senderNickname = "erased-admin-fan", senderNickname = "erased-admin-fan",
recipientNickname = "erased-admin-creator", recipientNickname = "erased-admin-creator",
withTracking = false withTracking = false
@@ -331,7 +332,7 @@ class AdminGiftControllerTest @Autowired constructor(
mockMvc.perform(get("/api/v2/admin/gifts/{applicationNo}", gift.applicationNo).with(user("admin").roles("ADMIN"))) mockMvc.perform(get("/api/v2/admin/gifts/{applicationNo}", gift.applicationNo).with(user("admin").roles("ADMIN")))
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("DELIVERED")) .andExpect(jsonPath("$.data.status").value(giftStatus.name))
.andExpect(jsonPath("$.data.senderInfo.nickname").value("erased-admin-fan")) .andExpect(jsonPath("$.data.senderInfo.nickname").value("erased-admin-fan"))
.andExpect(jsonPath("$.data.senderInfo.name").value("")) .andExpect(jsonPath("$.data.senderInfo.name").value(""))
.andExpect(jsonPath("$.data.senderInfo.phoneNumber").value("")) .andExpect(jsonPath("$.data.senderInfo.phoneNumber").value(""))
@@ -359,9 +360,10 @@ class AdminGiftControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.items[0].availableActions").isEmpty) .andExpect(jsonPath("$.data.items[0].availableActions").isEmpty)
} }
@Test @ParameterizedTest
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
@DisplayName("실제 Spring 개인정보 파기는 JWT HTTP 선물함 계약과 결제·동의·리뷰·이력을 보존한다") @DisplayName("실제 Spring 개인정보 파기는 JWT HTTP 선물함 계약과 결제·동의·리뷰·이력을 보존한다")
fun shouldEraseDeliveredGiftAndPreserveRealJwtHttpContracts() { fun shouldEraseGiftAndPreserveRealJwtHttpContracts(status: GiftStatus) {
val now = NOW.plusDays(14) val now = NOW.plusDays(14)
val recipient = memberRepository.saveAndFlush( val recipient = memberRepository.saveAndFlush(
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "erasure-recipient") Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "erasure-recipient")
@@ -376,7 +378,7 @@ class AdminGiftControllerTest @Autowired constructor(
val review = requireNotNull( val review = requireNotNull(
transaction.execute { transaction.execute {
val gift = giftRepository.findById(giftId).orElseThrow() val gift = giftRepository.findById(giftId).orElseThrow()
gift.status = GiftStatus.DELIVERED gift.status = status
gift.recipientTermsAgreed = true gift.recipientTermsAgreed = true
gift.recipientPrivacyAgreed = true gift.recipientPrivacyAgreed = true
gift.recipientTermsAgreedAt = NOW gift.recipientTermsAgreedAt = NOW
@@ -393,7 +395,9 @@ class AdminGiftControllerTest @Autowired constructor(
delivery.trackingRegisteredAt = NOW.minusDays(2) delivery.trackingRegisteredAt = NOW.minusDays(2)
delivery.arrivedAtMailboxAt = NOW.minusDays(1) delivery.arrivedAtMailboxAt = NOW.minusDays(1)
delivery.inspectionCompletedAt = NOW.plusDays(1) delivery.inspectionCompletedAt = NOW.plusDays(1)
delivery.deliveredAt = now.minusDays(7) delivery.deliveredAt = now.minusDays(7).takeIf { status == GiftStatus.DELIVERED }
delivery.undeliverableAt = now.minusDays(7).takeIf { status == GiftStatus.UNDELIVERABLE }
delivery.undeliverableReason = "검수 실패".takeIf { status == GiftStatus.UNDELIVERABLE }
delivery.recipientAddressDeadlineAt = NOW.plusDays(5) delivery.recipientAddressDeadlineAt = NOW.plusDays(5)
delivery.trackingDeadlineReminderSentAt = NOW.minusDays(3) delivery.trackingDeadlineReminderSentAt = NOW.minusDays(3)
delivery.recipientAddressDeadlineReminderSentAt = NOW.plusDays(4) delivery.recipientAddressDeadlineReminderSentAt = NOW.plusDays(4)
@@ -437,8 +441,8 @@ class AdminGiftControllerTest @Autowired constructor(
listOf(false, true).forEach { erased -> listOf(false, true).forEach { erased ->
if (erased) { if (erased) {
assertFalse(TransactionSynchronizationManager.isActualTransactionActive()) assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(now)) assertEquals(1, scheduler.eraseGiftPersonalData(now))
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(now)) assertEquals(0, scheduler.eraseGiftPersonalData(now))
assertFalse(TransactionSynchronizationManager.isActualTransactionActive()) assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
} }
listOf(detailPath, adminPath, adminListPath, "/api/v2/gifts?type=SENT", "/api/v2/gifts?type=RECEIVED").forEach { listOf(detailPath, adminPath, adminListPath, "/api/v2/gifts?type=SENT", "/api/v2/gifts?type=RECEIVED").forEach {
@@ -476,16 +480,27 @@ class AdminGiftControllerTest @Autowired constructor(
originalTimeline = originalTimeline ?: sentData.path("statusTimeline") originalTimeline = originalTimeline ?: sentData.path("statusTimeline")
assertTrue(requireNotNull(originalTimeline).isArray) assertTrue(requireNotNull(originalTimeline).isArray)
assertEquals(5, requireNotNull(originalTimeline).size()) assertEquals(5, requireNotNull(originalTimeline).size())
assertEquals(json.textNode("2026-10-10T12:00:00Z"), requireNotNull(originalTimeline).last().path("occurredAt")) assertEquals(
if (status == GiftStatus.DELIVERED) json.textNode("2026-10-10T12:00:00Z") else json.nullNode(),
requireNotNull(originalTimeline).last().path("occurredAt")
)
listOf(sentData, receivedData).forEach { data -> listOf(sentData, receivedData).forEach { data ->
assertEquals(originalTimeline, data.path("statusTimeline")) assertEquals(originalTimeline, data.path("statusTimeline"))
assertEquals(
if (status == GiftStatus.UNDELIVERABLE) json.textNode("2026-10-10T12:00:00Z") else json.nullNode(),
data.path("delivery").path("undeliverableAt")
)
assertEquals(
if (status == GiftStatus.UNDELIVERABLE) json.textNode("검수 실패") else json.nullNode(),
data.path("delivery").path("undeliverableReason")
)
assertEquals(json.booleanNode(false), data.path("trackingRequired")) assertEquals(json.booleanNode(false), data.path("trackingRequired"))
assertEquals(json.booleanNode(false), data.path("recipientAddressRequired")) assertEquals(json.booleanNode(false), data.path("recipientAddressRequired"))
assertEquals(json.nullNode(), data.path("mailbox")) assertEquals(json.nullNode(), data.path("mailbox"))
} }
listOf(sentData, receivedData, adminData).forEach { data -> listOf(sentData, receivedData, adminData).forEach { data ->
assertEquals(json.textNode(APPLICATION_NO), data.path("applicationNo")) assertEquals(json.textNode(APPLICATION_NO), data.path("applicationNo"))
assertEquals(json.textNode("DELIVERED"), data.path("status")) assertEquals(json.textNode(status.name), data.path("status"))
} }
listOf("senderInfo" to senderAddress, "recipientInfo" to recipientAddress).forEach { (field, address) -> listOf("senderInfo" to senderAddress, "recipientInfo" to recipientAddress).forEach { (field, address) ->
val nickname = if (field == "senderInfo") fixture.sender.nickname else recipient.nickname val nickname = if (field == "senderInfo") fixture.sender.nickname else recipient.nickname
@@ -499,7 +514,7 @@ class AdminGiftControllerTest @Autowired constructor(
assertEquals(json.numberNode(1), adminList.path("data").path("totalCount")) assertEquals(json.numberNode(1), adminList.path("data").path("totalCount"))
val adminItem = adminList.path("data").path("items").single() val adminItem = adminList.path("data").path("items").single()
assertEquals(json.textNode(APPLICATION_NO), adminItem.path("applicationNo")) assertEquals(json.textNode(APPLICATION_NO), adminItem.path("applicationNo"))
assertEquals(json.textNode("DELIVERED"), adminItem.path("status")) assertEquals(json.textNode(status.name), adminItem.path("status"))
assertEquals(json.textNode(fixture.sender.nickname), adminItem.path("senderNickname")) assertEquals(json.textNode(fixture.sender.nickname), adminItem.path("senderNickname"))
assertEquals(json.textNode(recipient.nickname), adminItem.path("recipientNickname")) assertEquals(json.textNode(recipient.nickname), adminItem.path("recipientNickname"))
listOf(adminData.path("inboundDeliveryInfo"), adminItem).forEach { data -> listOf(adminData.path("inboundDeliveryInfo"), adminItem).forEach { data ->
@@ -514,13 +529,13 @@ class AdminGiftControllerTest @Autowired constructor(
assertEquals(json.numberNode(1), list.path("data").path("totalCount")) assertEquals(json.numberNode(1), list.path("data").path("totalCount"))
val item = list.path("data").path("items").single() val item = list.path("data").path("items").single()
assertEquals(json.textNode(APPLICATION_NO), item.path("applicationNo")) assertEquals(json.textNode(APPLICATION_NO), item.path("applicationNo"))
assertEquals(json.textNode("DELIVERED"), item.path("status")) assertEquals(json.textNode(status.name), item.path("status"))
assertEquals(json.textNode(direction), item.path("direction")) assertEquals(json.textNode(direction), item.path("direction"))
assertEquals(json.textNode(nickname), item.path("counterpartNickname")) assertEquals(json.textNode(nickname), item.path("counterpartNickname"))
assertEquals(json.booleanNode(!erased), item.path("recipientAddressRegistered")) assertEquals(json.booleanNode(!erased), item.path("recipientAddressRegistered"))
} }
println( println(
"P15_HTTP erased=$erased status=200 success=true " + "P17_HTTP giftStatus=$status erased=$erased status=200 success=true " +
"senderStringsEmpty=${sentData.path("senderInfo").all { it.isTextual && it.textValue().isEmpty() }} " + "senderStringsEmpty=${sentData.path("senderInfo").all { it.isTextual && it.textValue().isEmpty() }} " +
"recipientAddressNull=${receivedData.path("recipientAddress").isNull} " + "recipientAddressNull=${receivedData.path("recipientAddress").isNull} " +
"trackingNull=${sentData.path("giftInfo").path("tracking").isNull} " + "trackingNull=${sentData.path("giftInfo").path("tracking").isNull} " +
@@ -549,7 +564,8 @@ class AdminGiftControllerTest @Autowired constructor(
.refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId) .refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId)
Mockito.verifyNoInteractions(giftPushService) Mockito.verifyNoInteractions(giftPushService)
println( println(
"P15_DB erased=1 repeated=0 persistedNulls=12 giftRows=1 deliveryRows=1 paymentConsentReviewHistoryPreserved=true" "P17_DB giftStatus=$status erased=1 repeated=0 persistedNulls=12 giftRows=1 deliveryRows=1 " +
"paymentConsentReviewHistoryPreserved=true"
) )
} }
@@ -22,6 +22,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import org.mockito.Mockito import org.mockito.Mockito
import org.redisson.api.RLock import org.redisson.api.RLock
@@ -608,43 +609,51 @@ class GiftSchedulerTest @Autowired constructor(
Mockito.verifyNoInteractions(giftPushService) Mockito.verifyNoInteractions(giftPushService)
} }
@Test @ParameterizedTest
@DisplayName("전달 완료 후 정확히 7일인 선물의 배송 개인정보 12개를 DB에서 지운다") @EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
fun shouldEraseDeliveredGiftWhenDeliveredAtEqualsSevenDayCutoff() { @DisplayName("상태와 짝인 시각부터 정확히 7일인 선물의 배송 개인정보 12개를 DB에서 지운다")
val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF) fun shouldEraseGiftWhenMatchingTimestampEqualsSevenDayCutoff(status: GiftStatus) {
val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF, status)
flushAndClear() flushAndClear()
val eligibleGifts = giftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(ERASURE_CUTOFF) assertEquals(1, scheduler.eraseGiftPersonalData(ERASURE_NOW))
val eligibleGifts = giftRepository.findGiftsWithPersonalDataRetentionExpired(ERASURE_CUTOFF)
assertEquals(listOf(gift.id), eligibleGifts.map { it.id }) assertEquals(listOf(gift.id), eligibleGifts.map { it.id })
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
flushAndClear() flushAndClear()
assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
} }
@Test @ParameterizedTest
@DisplayName("전달 완료 후 7일보다 1초 덜 지난 선물의 개인정보는 보존한다") @EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
fun shouldIgnoreDeliveredGiftOneSecondNewerThanSevenDayCutoff() { @DisplayName("짝인 시각부터 7일보다 1초 덜 지났으면 다른 시각이 오래돼도 개인정보를 보존한다")
val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF.plusSeconds(1)) fun shouldIgnoreGiftOneSecondNewerThanSevenDayCutoffDespiteOldOtherTimestamp(status: GiftStatus) {
val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF.plusSeconds(1), status)
if (status == GiftStatus.DELIVERED) {
delivery.undeliverableAt = ERASURE_CUTOFF.minusDays(30)
} else {
delivery.deliveredAt = ERASURE_CUTOFF.minusDays(30)
}
val originalPersonalData = personalData(delivery) val originalPersonalData = personalData(delivery)
flushAndClear() flushAndClear()
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) assertEquals(0, scheduler.eraseGiftPersonalData(ERASURE_NOW))
flushAndClear() flushAndClear()
assertEquals(originalPersonalData, personalData(reloadDelivery(gift))) assertEquals(originalPersonalData, personalData(reloadDelivery(gift)))
} }
@Test @ParameterizedTest
@DisplayName("도입 시점보다 오래전에 생성되고 전달 완료된 과거 누적 선물도 지운다") @EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
fun shouldEraseOlderHistoricalDeliveredGifts() { @DisplayName("도입 시점보다 오래전에 생성되고 종료된 과거 누적 선물도 지운다")
fun shouldEraseOlderHistoricalGifts(status: GiftStatus) {
val historicalAt = LocalDateTime.of(2020, 1, 1, 12, 0) val historicalAt = LocalDateTime.of(2020, 1, 1, 12, 0)
val (gift, delivery) = saveGiftForErasure(historicalAt.plusDays(10)) val (gift, delivery) = saveGiftForErasure(historicalAt.plusDays(10), status)
gift.createdAt = historicalAt gift.createdAt = historicalAt
delivery.createdAt = historicalAt delivery.createdAt = historicalAt
flushAndClear() flushAndClear()
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) assertEquals(1, scheduler.eraseGiftPersonalData(ERASURE_NOW))
flushAndClear() flushAndClear()
assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
@@ -653,17 +662,29 @@ class GiftSchedulerTest @Autowired constructor(
} }
@Test @Test
@DisplayName("완료 시각이 없거나 전달 완료가 아닌 모든 상태는 개인정보를 보존한다") @DisplayName("짝인 시각이 없거나 지원하지 않는 상태는 다른 시각이 오래돼도 개인정보를 보존한다")
fun shouldIgnoreNullDeliveredAtAndNonDeliveredGifts() { fun shouldIgnoreNullMatchingTimestampsAndUnsupportedStatuses() {
val gifts = listOf(saveGiftForErasure(null)) + GiftStatus.values() val supportedStatuses = setOf(GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE)
.filter { it != GiftStatus.DELIVERED } val gifts = supportedStatuses.map { status ->
saveGiftForErasure(null, status, "NULL-$status").also { (_, delivery) ->
if (status == GiftStatus.DELIVERED) {
delivery.undeliverableAt = ERASURE_CUTOFF.minusDays(30)
} else {
delivery.deliveredAt = ERASURE_CUTOFF.minusDays(30)
}
}
} + GiftStatus.values()
.filter { it !in supportedStatuses }
.mapIndexed { index, status -> .mapIndexed { index, status ->
saveGiftForErasure(ERASURE_CUTOFF.minusDays(30), status, "IGNORE-$index") saveGiftForErasure(null, status, "IGNORE-$index").also { (_, delivery) ->
delivery.deliveredAt = ERASURE_CUTOFF.minusDays(30)
delivery.undeliverableAt = ERASURE_CUTOFF.minusDays(30)
}
} }
val originals = gifts.map { (_, delivery) -> personalData(delivery) } val originals = gifts.map { (_, delivery) -> personalData(delivery) }
flushAndClear() flushAndClear()
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) assertEquals(0, scheduler.eraseGiftPersonalData(ERASURE_NOW))
flushAndClear() flushAndClear()
gifts.forEachIndexed { index, (gift, _) -> gifts.forEachIndexed { index, (gift, _) ->
@@ -672,56 +693,60 @@ class GiftSchedulerTest @Autowired constructor(
} }
} }
@Test @ParameterizedTest
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
@DisplayName("12개 개인정보 중 어느 하나만 남아 있어도 나머지 값을 DB에서 지운다") @DisplayName("12개 개인정보 중 어느 하나만 남아 있어도 나머지 값을 DB에서 지운다")
fun shouldEraseRemainingFieldsFromPartiallyClearedGift() { fun shouldEraseRemainingFieldsFromPartiallyClearedGift(status: GiftStatus) {
val gifts = PERSONAL_DATA_FIELDS.mapIndexed { index, remainingField -> val gifts = PERSONAL_DATA_FIELDS.mapIndexed { index, remainingField ->
saveGiftForErasure(ERASURE_CUTOFF, applicationNo = "PARTIAL-$index").also { (_, delivery) -> saveGiftForErasure(ERASURE_CUTOFF, status, "PARTIAL-$index").also { (_, delivery) ->
PERSONAL_DATA_FIELDS.filter { it != remainingField }.forEach { it.set(delivery, null) } PERSONAL_DATA_FIELDS.filter { it != remainingField }.forEach { it.set(delivery, null) }
} }
} }
flushAndClear() flushAndClear()
assertEquals(12, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) assertEquals(12, scheduler.eraseGiftPersonalData(ERASURE_NOW))
flushAndClear() flushAndClear()
gifts.forEach { (gift, _) -> assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) } gifts.forEach { (gift, _) -> assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) }
} }
@Test @ParameterizedTest
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
@DisplayName("이미 개인정보가 모두 null인 선물은 첫 실행부터 변경하지 않는다") @DisplayName("이미 개인정보가 모두 null인 선물은 첫 실행부터 변경하지 않는다")
fun shouldReturnZeroWhenAllPersonalDataIsAlreadyNull() { fun shouldReturnZeroWhenAllPersonalDataIsAlreadyNull(status: GiftStatus) {
val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF) val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF, status)
PERSONAL_DATA_FIELDS.forEach { it.set(delivery, null) } PERSONAL_DATA_FIELDS.forEach { it.set(delivery, null) }
flushAndClear() flushAndClear()
val updatedAt = reloadDelivery(gift).updatedAt val updatedAt = reloadDelivery(gift).updatedAt
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) assertEquals(0, scheduler.eraseGiftPersonalData(ERASURE_NOW))
flushAndClear() flushAndClear()
assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
assertEquals(updatedAt, reloadDelivery(gift).updatedAt) assertEquals(updatedAt, reloadDelivery(gift).updatedAt)
} }
@Test @ParameterizedTest
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
@DisplayName("개인정보 정리를 재실행하면 추가 변경 없이 0건을 반환한다") @DisplayName("개인정보 정리를 재실행하면 추가 변경 없이 0건을 반환한다")
fun shouldReturnZeroWhenErasureRunsAgain() { fun shouldReturnZeroWhenErasureRunsAgain(status: GiftStatus) {
val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF) val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF, status)
flushAndClear() flushAndClear()
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) assertEquals(1, scheduler.eraseGiftPersonalData(ERASURE_NOW))
flushAndClear() flushAndClear()
val updatedAt = reloadDelivery(gift).updatedAt val updatedAt = reloadDelivery(gift).updatedAt
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW.plusDays(1))) assertEquals(0, scheduler.eraseGiftPersonalData(ERASURE_NOW.plusDays(1)))
flushAndClear() flushAndClear()
assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
assertEquals(updatedAt, reloadDelivery(gift).updatedAt) assertEquals(updatedAt, reloadDelivery(gift).updatedAt)
} }
@Test @ParameterizedTest
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
@DisplayName("개인정보 정리는 선물·회원·결제·동의·리뷰와 업무 시각을 DB에 보존하고 환불·푸시하지 않는다") @DisplayName("개인정보 정리는 선물·회원·결제·동의·리뷰와 업무 시각을 DB에 보존하고 환불·푸시하지 않는다")
fun shouldPreserveGiftMemberPaymentConsentReviewAndBusinessTimestamps() { fun shouldPreserveGiftMemberPaymentConsentReviewAndBusinessTimestamps(status: GiftStatus) {
val sender = entityManager.persist( val sender = entityManager.persist(
Member(password = "test-password", nickname = "sender", profileImage = "sender-profile.png") Member(password = "test-password", nickname = "sender", profileImage = "sender-profile.png")
.apply { pgChargeCan = 120 } .apply { pgChargeCan = 120 }
@@ -733,7 +758,7 @@ class GiftSchedulerTest @Autowired constructor(
UseCan(canUsage = CanUsage.GIFT, can = 60, rewardCan = 20).apply { member = sender } UseCan(canUsage = CanUsage.GIFT, can = 60, rewardCan = 20).apply { member = sender }
) )
val (gift, delivery) = saveGift( val (gift, delivery) = saveGift(
GiftStatus.DELIVERED, status,
APPLIED_AT.plusDays(3), APPLIED_AT.plusDays(3),
senderMemberId = requireNotNull(sender.id), senderMemberId = requireNotNull(sender.id),
recipientMemberId = requireNotNull(recipient.id), recipientMemberId = requireNotNull(recipient.id),
@@ -752,7 +777,7 @@ class GiftSchedulerTest @Autowired constructor(
delivery.inspectionCompletedAt = APPLIED_AT.plusDays(4) delivery.inspectionCompletedAt = APPLIED_AT.plusDays(4)
delivery.deliveredAt = ERASURE_CUTOFF delivery.deliveredAt = ERASURE_CUTOFF
delivery.canceledAt = APPLIED_AT.plusHours(1) delivery.canceledAt = APPLIED_AT.plusHours(1)
delivery.undeliverableAt = APPLIED_AT.plusHours(2) delivery.undeliverableAt = if (status == GiftStatus.UNDELIVERABLE) ERASURE_CUTOFF else APPLIED_AT.plusHours(2)
delivery.undeliverableReason = "보존할 업무 사유" delivery.undeliverableReason = "보존할 업무 사유"
val review = entityManager.persist( val review = entityManager.persist(
GiftReview(requireNotNull(gift.id), requireNotNull(sender.id), 5, "[\"안전\"]", "감사합니다") GiftReview(requireNotNull(gift.id), requireNotNull(sender.id), 5, "[\"안전\"]", "감사합니다")
@@ -764,7 +789,7 @@ class GiftSchedulerTest @Autowired constructor(
val originalReview = entityManager.find(GiftReview::class.java, review.id) val originalReview = entityManager.find(GiftReview::class.java, review.id)
flushAndClear() flushAndClear()
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) assertEquals(1, scheduler.eraseGiftPersonalData(ERASURE_NOW))
flushAndClear() flushAndClear()
val reloadedGift = entityManager.find(Gift::class.java, gift.id) val reloadedGift = entityManager.find(Gift::class.java, gift.id)
@@ -849,13 +874,14 @@ class GiftSchedulerTest @Autowired constructor(
private fun personalData(delivery: GiftDelivery): List<String?> = PERSONAL_DATA_FIELDS.map { it.get(delivery) } private fun personalData(delivery: GiftDelivery): List<String?> = PERSONAL_DATA_FIELDS.map { it.get(delivery) }
private fun saveGiftForErasure( private fun saveGiftForErasure(
deliveredAt: LocalDateTime?, retentionStartedAt: LocalDateTime?,
status: GiftStatus = GiftStatus.DELIVERED, status: GiftStatus = GiftStatus.DELIVERED,
applicationNo: String = APPLICATION_NO applicationNo: String = APPLICATION_NO
): Pair<Gift, GiftDelivery> = saveGift(status, APPLIED_AT.plusDays(3), applicationNo = applicationNo) ): Pair<Gift, GiftDelivery> = saveGift(status, APPLIED_AT.plusDays(3), applicationNo = applicationNo)
.also { (_, delivery) -> .also { (_, delivery) ->
fillDeliveryPersonalData(delivery) fillDeliveryPersonalData(delivery)
delivery.deliveredAt = deliveredAt delivery.deliveredAt = retentionStartedAt.takeIf { status == GiftStatus.DELIVERED }
delivery.undeliverableAt = retentionStartedAt.takeIf { status == GiftStatus.UNDELIVERABLE }
} }
private fun fillDeliveryPersonalData(delivery: GiftDelivery) { private fun fillDeliveryPersonalData(delivery: GiftDelivery) {
@@ -928,11 +954,11 @@ class GiftSchedulerTest @Autowired constructor(
GiftScheduler::runExpiredRecipientAddressGiftExpiration GiftScheduler::runExpiredRecipientAddressGiftExpiration
), ),
ScheduledEntryPoint( ScheduledEntryPoint(
"runDeliveredGiftPersonalDataErasure", "runGiftPersonalDataErasure",
"eraseDeliveredGiftPersonalData", "eraseGiftPersonalData",
"0 20 * * * *", "0 20 * * * *",
"lock:gift-delivered-personal-data-erasure", "lock:gift-delivered-personal-data-erasure",
GiftScheduler::runDeliveredGiftPersonalDataErasure GiftScheduler::runGiftPersonalDataErasure
), ),
ScheduledEntryPoint( ScheduledEntryPoint(
"runDeadlineReminderGifts", "runDeadlineReminderGifts",