fix(gift): 누락된 발신자 푸시를 보완하고 가격 스냅샷을 검증한다
This commit is contained in:
+248
@@ -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
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user