fix(gift): 누락된 발신자 푸시를 보완하고 가격 스냅샷을 검증한다

This commit is contained in:
2026-10-26 10:26:12 +09:00
parent d052dd50de
commit cd865da6e0
5 changed files with 280 additions and 1 deletions
@@ -33,9 +33,11 @@ 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.GiftApplicationCommand
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.GiftSizePriceCommand
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
@@ -167,6 +169,7 @@ class AdminGiftControllerTest @Autowired constructor(
giftDeliveryRepository.deleteAll()
giftRepository.deleteAll()
categoryRepository.deleteAll()
sizePriceRepository.deleteAll()
}
@AfterEach
@@ -544,6 +547,78 @@ class AdminGiftControllerTest @Autowired constructor(
)
}
@Test
@DisplayName("실제 JWT HTTP 수령 확인은 발신자만 한 번 알리고 거부·반복 요청은 추가 알림을 보내지 않는다")
fun shouldConfirmDeliveryThroughRealJwtHttpAndNotifyOnlySenderOnce() {
val recipient = memberRepository.saveAndFlush(
Member(
email = "${UUID.randomUUID()}@test.com",
password = "password",
nickname = "confirmation-creator",
role = MemberRole.CREATOR
)
)
val fixture = saveFundedGift(recipient)
val giftId = requireNotNull(fixture.gift.id)
TransactionTemplate(transactionManager).executeWithoutResult {
giftRepository.findById(giftId).orElseThrow().status = GiftStatus.INSPECTION_COMPLETED
}
val path = "/api/v2/gifts/$APPLICATION_NO/delivery-complete"
giftHttp(HttpMethod.POST, path, expectedStatus = HttpStatus.UNAUTHORIZED)
val denied = giftHttp(HttpMethod.POST, path, tokenFor(fixture.sender))
assertFalse(denied.path("success").asBoolean())
assertEquals("Invalid request.", denied.path("message").asText())
assertEquals(GiftStatus.INSPECTION_COMPLETED, giftRepository.findById(giftId).orElseThrow().status)
assertEquals(null, giftDeliveryRepository.findByGiftId(giftId)?.deliveredAt)
Mockito.verifyNoInteractions(giftPushService)
val recipientToken = tokenFor(recipient)
val confirmed = giftHttp(HttpMethod.POST, path, recipientToken)
assertTrue(confirmed.path("success").asBoolean())
assertEquals(APPLICATION_NO, confirmed.path("data").path("applicationNo").asText())
assertEquals("DELIVERED", confirmed.path("data").path("status").asText())
val deliveredAt = requireNotNull(giftDeliveryRepository.findByGiftId(giftId)?.deliveredAt)
assertEquals(deliveredAt.toUtcIso(), confirmed.path("data").path("deliveredAt").asText())
assertEquals(GiftStatus.DELIVERED, giftRepository.findById(giftId).orElseThrow().status)
Mockito.verify(giftPushService).sendDeliveredToSender(APPLICATION_NO, requireNotNull(fixture.sender.id))
Mockito.verifyNoMoreInteractions(giftPushService)
val repeated = giftHttp(HttpMethod.POST, path, recipientToken)
assertFalse(repeated.path("success").asBoolean())
assertEquals("Invalid request.", repeated.path("message").asText())
assertEquals(deliveredAt, giftDeliveryRepository.findByGiftId(giftId)?.deliveredAt)
Mockito.verifyNoMoreInteractions(giftPushService)
assertRefundState(fixture, refunded = false)
println("P16_CONFIRM_HTTP status=200 success=true rejected=true repeated=false senderPush=1 recipientPush=0")
}
@Test
@DisplayName("실제 transactional 스케줄러 배송지 만료는 발신자만 한 번 알리고 재실행은 추가 알림을 보내지 않는다")
fun shouldExpireRecipientAddressThroughTransactionalSchedulerAndNotifyOnlySenderOnce() {
val fixture = saveFundedGift()
val giftId = requireNotNull(fixture.gift.id)
TransactionTemplate(transactionManager).executeWithoutResult {
giftRepository.findById(giftId).orElseThrow().status = GiftStatus.TRACKING_REGISTERED
requireNotNull(giftDeliveryRepository.findByGiftId(giftId)).recipientAddressDeadlineAt = NOW.minusSeconds(1)
}
assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
assertEquals(1, scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW))
assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
assertEquals(GiftStatus.UNDELIVERABLE, giftRepository.findById(giftId).orElseThrow().status)
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(giftId))
assertEquals(NOW, delivery.undeliverableAt)
assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason)
Mockito.verify(giftPushService).sendUndeliverableToSender(APPLICATION_NO, requireNotNull(fixture.sender.id))
Mockito.verifyNoMoreInteractions(giftPushService)
assertEquals(0, scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW.plusMinutes(1)))
assertEquals(NOW, giftDeliveryRepository.findByGiftId(giftId)?.undeliverableAt)
Mockito.verifyNoMoreInteractions(giftPushService)
assertRefundState(fixture, refunded = false)
println("P16_EXPIRY_DB applicationNo=$APPLICATION_NO transitioned=1 repeated=0 senderPush=1 recipientPush=0")
}
@Test
@DisplayName("관리자는 카테고리를 등록, 조회, 수정, 논리삭제한다")
fun shouldManageGiftCategories() {
@@ -635,6 +710,166 @@ class AdminGiftControllerTest @Autowired constructor(
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("실제 신청은 가격 변경과 카테고리 비활성화 후에도 기존 가격·카테고리명·결제 스냅샷을 보존한다")
fun shouldPreserveExistingGiftSnapshotsAfterPriceChangeAndCategoryDeactivation() {
val transaction = TransactionTemplate(transactionManager)
val sender = memberRepository.saveAndFlush(
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "snapshot-sender").apply {
pgChargeCan = 300
}
)
val creator = memberRepository.saveAndFlush(
Member(
email = "${UUID.randomUUID()}@test.com",
password = "password",
nickname = "snapshot-creator",
role = MemberRole.CREATOR
)
)
assertEquals(creator.id, memberRepository.findCreatorByIdOrNull(requireNotNull(creator.id))?.id)
val funding = chargeRepository.saveAndFlush(
Charge(300, 0).apply {
member = sender
payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
}
)
val category = categoryRepository.saveAndFlush(
GiftCategory("100", "SNAPSHOT", "인형", "A", "피규어")
)
val categoryId = requireNotNull(category.id)
val senderId = requireNotNull(sender.id)
val originalUseCount = useCanRepository.count()
val originalCalculateCount = calculateRepository.count()
val originalChargeCount = chargeRepository.count()
adminService.updateSizePrice(GiftSize.SMALL, GiftSizePriceCommand(150, 80, true))
val command = GiftApplicationCommand(
recipientMemberId = requireNotNull(creator.id),
senderName = "sender",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "test address",
senderAddressDetail = null,
sizeCode = GiftSize.SMALL,
categoryId = categoryId,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
damageWaiverAgreed = false
)
val first = commandService.registerGift(sender, command)
assertEquals(80, first.priceCan)
val firstUsageId = requireNotNull(
transaction.execute {
entityManager.flush()
entityManager.clear()
val gift = requireNotNull(giftRepository.findByApplicationNo(first.applicationNo))
assertEquals(80, gift.salePriceCan)
assertEquals("인형", gift.categoryNameSnapshot)
assertEquals(80, useCanRepository.findById(gift.canUsageId).orElseThrow().can)
assertEquals(220, memberRepository.findById(senderId).orElseThrow().pgChargeCan)
assertEquals(220, chargeRepository.findById(requireNotNull(funding.id)).orElseThrow().chargeCan)
gift.canUsageId
}
)
adminService.updateSizePrice(GiftSize.SMALL, GiftSizePriceCommand(150, 120, true))
val second = commandService.registerGift(sender, command)
assertEquals(120, second.priceCan)
assertNotEquals(first.applicationNo, second.applicationNo)
val prices = mapOf(first.applicationNo to 80, second.applicationNo to 120)
val usageIds = prices.keys.associateWith {
requireNotNull(giftRepository.findByApplicationNo(it)).canUsageId
}
assertEquals(firstUsageId, usageIds[first.applicationNo])
assertNotEquals(firstUsageId, usageIds[second.applicationNo])
val senderToken = tokenFor(sender)
listOf(false, true).forEach { deactivated ->
if (deactivated) {
assertFalse(adminService.deleteCategory(categoryId).isActive)
val exception = assertThrows(SodaException::class.java) { commandService.registerGift(sender, command) }
assertEquals("common.error.invalid_request", exception.messageKey)
}
transaction.executeWithoutResult {
entityManager.flush()
entityManager.clear()
assertEquals(!deactivated, categoryRepository.findById(categoryId).orElseThrow().isActive)
assertEquals(120, requireNotNull(sizePriceRepository.findBySizeCode(GiftSize.SMALL)).salePriceCan)
prices.forEach { (applicationNo, price) ->
val gift = requireNotNull(giftRepository.findByApplicationNo(applicationNo))
assertEquals(price, gift.salePriceCan)
assertEquals("인형", gift.categoryNameSnapshot)
assertEquals(categoryId, gift.categoryId)
assertEquals(usageIds[applicationNo], gift.canUsageId)
val usage = useCanRepository.findById(gift.canUsageId).orElseThrow()
assertEquals(CanUsage.GIFT, usage.canUsage)
assertEquals(senderId, usage.member?.id)
assertEquals(price, usage.can)
assertEquals(0, usage.rewardCan)
assertFalse(usage.isRefund)
val calculations = calculateRepository.findByUseCanIdAndStatus(
gift.canUsageId,
UseCanCalculateStatus.RECEIVED
)
assertEquals(1, calculations.size)
assertEquals(price, calculations.single().can)
assertEquals(PaymentGateway.PG, calculations.single().paymentGateway)
}
val reloadedSender = memberRepository.findById(senderId).orElseThrow()
val reloadedFunding = chargeRepository.findById(requireNotNull(funding.id)).orElseThrow()
assertEquals(100, reloadedSender.pgChargeCan)
assertEquals(0, reloadedSender.pgRewardCan)
assertEquals(100, reloadedFunding.chargeCan)
assertEquals(0, reloadedFunding.rewardCan)
assertEquals(ChargeStatus.CHARGE, reloadedFunding.status)
assertEquals(PaymentStatus.COMPLETE, reloadedFunding.payment?.status)
assertEquals(PaymentGateway.PG, reloadedFunding.payment?.paymentGateway)
assertEquals(2L, giftRepository.count())
assertEquals(2L, giftDeliveryRepository.count())
assertEquals(originalUseCount + 2, useCanRepository.count())
assertEquals(originalCalculateCount + 2, calculateRepository.count())
assertEquals(originalChargeCount, chargeRepository.count())
}
val options = giftHttp(HttpMethod.GET, "/api/v2/gifts/form-options", senderToken)
assertTrue(options.path("success").asBoolean())
assertEquals(
!deactivated,
options.path("data").path("categories").any { it.path("categoryId").asLong() == categoryId }
)
assertEquals(
120,
options.path("data").path("sizes").single { it.path("sizeCode").asText() == "SMALL" }
.path("salePriceCan").asInt()
)
prices.forEach { (applicationNo, price) ->
val detail = giftHttp(HttpMethod.GET, "/api/v2/gifts/$applicationNo", senderToken)
assertTrue(detail.path("success").asBoolean())
assertEquals(applicationNo, detail.path("data").path("applicationNo").asText())
assertEquals(price, detail.path("data").path("giftInfo").path("paidCan").asInt())
assertEquals("인형", detail.path("data").path("giftInfo").path("categoryName").asText())
println("P16_SNAPSHOT_HTTP deactivated=$deactivated applicationNo=$applicationNo paidCan=$price categoryName=인형")
}
val list = giftHttp(HttpMethod.GET, "/api/v2/gifts?type=SENT", senderToken)
assertTrue(list.path("success").asBoolean())
assertEquals(2, list.path("data").path("totalCount").asInt())
val items = list.path("data").path("items").associateBy { it.path("applicationNo").asText() }
assertEquals(prices.keys, items.keys)
prices.forEach { (applicationNo, price) ->
val item = requireNotNull(items[applicationNo])
assertEquals(price, item.path("priceCan").asInt())
assertEquals("인형", item.path("categoryName").asText())
}
println(
"P16_SNAPSHOT_DB deactivated=$deactivated A=80 B=120 categoryName=인형 usageIdsPreserved=true " +
"actualUseCan=80,120 memberBalance=100 chargeBalance=100 gifts=2 usesDelta=2 chargesDelta=0 listCount=2"
)
}
Mockito.verify(canPaymentService).spendGiftCan(senderId, 80, "web")
Mockito.verify(canPaymentService).spendGiftCan(senderId, 120, "web")
assertEquals(2, Mockito.mockingDetails(canPaymentService).invocations.count { it.method.name == "spendGiftCan" })
Mockito.verifyNoInteractions(giftPushService)
}
@Test
@DisplayName("관리자는 사서함 도착과 검수완료를 처리한다")
fun shouldUpdateGiftOperationStatuses() {
@@ -1157,6 +1392,19 @@ class AdminGiftControllerTest @Autowired constructor(
assertEquals(0, scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW))
Mockito.verify(giftPushService, Mockito.times(if (operations.first == RecipientMutation.REMINDER) 1 else 0))
.sendRecipientAddressDeadlineReminder(APPLICATION_NO, fixture.gift.recipientMemberId)
if (
listOf(operations.first, operations.second).any {
it in listOf(RecipientMutation.CONFIRM, RecipientMutation.EXPIRY)
}
) {
val confirmed = RecipientMutation.CONFIRM in listOf(operations.first, operations.second) &&
operations.first != RecipientMutation.EXPIRY
Mockito.verify(giftPushService, Mockito.times(if (confirmed) 1 else 0))
.sendDeliveredToSender(APPLICATION_NO, requireNotNull(fixture.sender.id))
Mockito.verify(giftPushService, Mockito.times(if (operations.first == RecipientMutation.EXPIRY) 1 else 0))
.sendUndeliverableToSender(APPLICATION_NO, requireNotNull(fixture.sender.id))
Mockito.verifyNoMoreInteractions(giftPushService)
}
assertRefundState(fixture, refunded = false)
println(
"P15_R1_RACE $operations first=$firstResult second=$secondResult serialized=$blockedUntilCommit persisted=true"
@@ -241,7 +241,26 @@ class GiftCommandServiceTest {
)
assertEquals(GiftStatus.DELIVERED, fixture.deliveryConfirmationGift.status)
assertEquals(NOW, fixture.deliveryConfirmationDelivery.deliveredAt)
Mockito.verifyNoInteractions(fixture.giftPushService)
Mockito.verify(fixture.giftPushService).sendDeliveredToSender(APPLICATION_NO, SENDER_ID)
Mockito.verifyNoMoreInteractions(fixture.giftPushService)
}
@Test
@DisplayName("수령 확인을 반복하면 전달 완료 시각을 보존하고 발신자에게 추가 알림을 보내지 않는다")
fun shouldNotNotifySenderAgainForRepeatedDeliveryConfirmation() {
val fixture = GiftApplicationFixture()
fixture.prepareGiftForDeliveryConfirmation()
fixture.service.confirmDelivery(fixture.creator, APPLICATION_NO)
val exception = assertThrows(SodaException::class.java) {
fixture.service.confirmDelivery(fixture.creator, APPLICATION_NO)
}
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(GiftStatus.DELIVERED, fixture.deliveryConfirmationGift.status)
assertEquals(NOW, fixture.deliveryConfirmationDelivery.deliveredAt)
Mockito.verify(fixture.giftPushService).sendDeliveredToSender(APPLICATION_NO, SENDER_ID)
Mockito.verifyNoMoreInteractions(fixture.giftPushService)
}
@Test
@@ -309,6 +328,7 @@ class GiftCommandServiceTest {
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(scenario.status, fixture.deliveryConfirmationGift.status)
assertEquals(null, fixture.deliveryConfirmationDelivery.deliveredAt)
Mockito.verifyNoInteractions(fixture.giftPushService)
}
@ParameterizedTest
@@ -415,6 +415,8 @@ class GiftSchedulerTest @Autowired constructor(
assertEquals(GiftStatus.UNDELIVERABLE, gift.status)
assertEquals(now, delivery.undeliverableAt)
assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason)
Mockito.verify(giftPushService).sendUndeliverableToSender(APPLICATION_NO, SENDER_MEMBER_ID)
Mockito.verifyNoMoreInteractions(giftPushService)
}
@Test
@@ -437,7 +439,9 @@ class GiftSchedulerTest @Autowired constructor(
assertEquals(GiftStatus.UNDELIVERABLE, gift.status)
assertEquals(now, delivery.undeliverableAt)
assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason)
Mockito.verify(giftPushService).sendUndeliverableToSender(gift.applicationNo, SENDER_MEMBER_ID)
}
Mockito.verifyNoMoreInteractions(giftPushService)
}
@Test
@@ -456,6 +460,7 @@ class GiftSchedulerTest @Autowired constructor(
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
assertEquals(null, delivery.undeliverableAt)
assertEquals(null, delivery.undeliverableReason)
Mockito.verifyNoInteractions(giftPushService)
}
@Test
@@ -475,6 +480,7 @@ class GiftSchedulerTest @Autowired constructor(
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
assertEquals(null, delivery.undeliverableAt)
assertEquals(null, delivery.undeliverableReason)
Mockito.verifyNoInteractions(giftPushService)
}
@Test
@@ -515,6 +521,7 @@ class GiftSchedulerTest @Autowired constructor(
assertEquals(null, delivery.undeliverableAt)
assertEquals(null, delivery.undeliverableReason)
}
Mockito.verifyNoInteractions(giftPushService)
}
@Test
@@ -533,6 +540,8 @@ class GiftSchedulerTest @Autowired constructor(
assertEquals(1, firstUpdatedCount)
assertEquals(0, secondUpdatedCount)
assertEquals(now, delivery.undeliverableAt)
Mockito.verify(giftPushService).sendUndeliverableToSender(APPLICATION_NO, SENDER_MEMBER_ID)
Mockito.verifyNoMoreInteractions(giftPushService)
}
@Test