feat(gift): 관리자 취소 API와 환불 경합 검증을 추가한다
This commit is contained in:
+540
-1
@@ -1,7 +1,26 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
@@ -11,16 +30,42 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepo
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.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.GiftCommandService
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
|
||||
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.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
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.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.boot.test.mock.mockito.SpyBean
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||
import org.springframework.boot.test.web.server.LocalServerPort
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockHttpServletRequest
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.test.annotation.DirtiesContext
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
@@ -29,11 +74,24 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delet
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
||||
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.support.TransactionTemplate
|
||||
import org.springframework.web.context.request.RequestContextHolder
|
||||
import org.springframework.web.context.request.ServletRequestAttributes
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest(
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = [
|
||||
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||
"spring.datasource.url=jdbc:h2:mem:gift-admin-controller;MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;" +
|
||||
@@ -50,15 +108,41 @@ class AdminGiftControllerTest @Autowired constructor(
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val memberRepository: MemberRepository
|
||||
private val memberRepository: MemberRepository,
|
||||
private val chargeRepository: ChargeRepository,
|
||||
private val useCanRepository: UseCanRepository,
|
||||
private val calculateRepository: UseCanCalculateRepository,
|
||||
private val commandService: GiftCommandService,
|
||||
private val scheduler: GiftScheduler,
|
||||
private val transactionManager: PlatformTransactionManager,
|
||||
private val entityManager: EntityManager,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val restTemplate: TestRestTemplate,
|
||||
private val tokenProvider: TokenProvider
|
||||
) {
|
||||
@LocalServerPort
|
||||
private var port: Int = 0
|
||||
|
||||
@SpyBean
|
||||
private lateinit var canPaymentService: CanPaymentService
|
||||
|
||||
@MockBean
|
||||
private lateinit var giftPushService: GiftPushService
|
||||
|
||||
@BeforeEach
|
||||
fun deleteGiftFixtures() {
|
||||
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest()))
|
||||
giftDeliveryRepository.deleteAll()
|
||||
giftRepository.deleteAll()
|
||||
categoryRepository.deleteAll()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun clearRequestContext() {
|
||||
(RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.requestCompleted()
|
||||
RequestContextHolder.resetRequestAttributes()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 받을 주소를 조회하고 저장한다")
|
||||
fun shouldManageGiftMailbox() {
|
||||
@@ -352,6 +436,448 @@ class AdminGiftControllerTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("발신자가 아닌 관리자는 접수 선물을 취소하고 원 결제액을 발신자 리워드캔으로 한 번만 환불한다")
|
||||
fun shouldCancelReceivedGiftAndRefundOriginalSenderOnlyOnce() {
|
||||
val fixture = saveFundedGift()
|
||||
assertNotEquals(fixture.sender.id, fixture.admin.id)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
|
||||
val response = mockMvc.perform(cancelRequest(fixture))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
|
||||
.andExpect(jsonPath("$.data.status").value("CANCELED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("신청 취소"))
|
||||
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z")))
|
||||
.andReturn().response
|
||||
val data = objectMapper.readTree(response.contentAsByteArray).path("data")
|
||||
assertEquals(setOf("applicationNo", "status", "statusName", "occurredAt"), data.fieldNames().asSequence().toSet())
|
||||
val canceledAt = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertEquals(canceledAt.toUtcIso(), data.path("occurredAt").asText())
|
||||
assertEquals(GiftStatus.CANCELED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
|
||||
assertRefundState(fixture, refunded = true)
|
||||
|
||||
mockMvc.perform(cancelRequest(fixture))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) }
|
||||
assertEquals(canceledAt, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = true)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 JWT HTTP에서 관리자만 접수 선물의 취소 액션을 조회하고 한 번 취소·환불한다")
|
||||
fun shouldCancelReceivedGiftThroughRealJwtHttpAndRequeryActions() {
|
||||
val fixture = saveFundedGift()
|
||||
val listPath = "/api/v2/admin/gifts?applicationNo=$APPLICATION_NO"
|
||||
val detailPath = "/api/v2/admin/gifts/$APPLICATION_NO"
|
||||
val cancelPath = "$detailPath/cancel"
|
||||
val endpoints = listOf(HttpMethod.GET to listPath, HttpMethod.GET to detailPath, HttpMethod.POST to cancelPath)
|
||||
assertNotEquals(fixture.sender.id, fixture.admin.id)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
|
||||
endpoints.forEach { (method, path) ->
|
||||
giftHttp(method, path, expectedStatus = HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
MemberRole.values().filter { it != MemberRole.ADMIN }.forEach { role ->
|
||||
val member = if (role == MemberRole.USER) {
|
||||
fixture.sender
|
||||
} else {
|
||||
memberRepository.saveAndFlush(
|
||||
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "nonadmin", role = role)
|
||||
)
|
||||
}
|
||||
val token = tokenFor(member)
|
||||
endpoints.forEach { (method, path) ->
|
||||
val denied = giftHttp(method, path, token)
|
||||
assertFalse(denied.path("success").asBoolean())
|
||||
assertEquals("You do not have permission.", denied.path("message").asText())
|
||||
assertTrue(denied.path("data").isNull)
|
||||
}
|
||||
}
|
||||
assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
|
||||
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
|
||||
val adminToken = tokenFor(fixture.admin)
|
||||
val beforeList = giftHttp(HttpMethod.GET, listPath, adminToken)
|
||||
val beforeDetail = giftHttp(HttpMethod.GET, detailPath, adminToken)
|
||||
listOf(beforeList, beforeDetail).forEach { assertTrue(it.path("success").asBoolean()) }
|
||||
assertEquals(1, beforeList.path("data").path("totalCount").asInt())
|
||||
listOf(beforeList.path("data").path("items").single(), beforeDetail.path("data")).forEach { data ->
|
||||
assertEquals(APPLICATION_NO, data.path("applicationNo").asText())
|
||||
assertEquals("RECEIVED", data.path("status").asText())
|
||||
assertEquals(listOf("CANCEL"), data.path("availableActions").map { it.asText() })
|
||||
}
|
||||
|
||||
val canceled = giftHttp(HttpMethod.POST, cancelPath, adminToken)
|
||||
assertTrue(canceled.path("success").asBoolean())
|
||||
val data = canceled.path("data")
|
||||
assertEquals(setOf("applicationNo", "status", "statusName", "occurredAt"), data.fieldNames().asSequence().toSet())
|
||||
assertEquals(APPLICATION_NO, data.path("applicationNo").asText())
|
||||
assertEquals("CANCELED", data.path("status").asText())
|
||||
assertEquals("신청 취소", data.path("statusName").asText())
|
||||
assertTrue(data.path("occurredAt").asText().endsWith("Z"))
|
||||
val canceledAt = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertEquals(canceledAt.toUtcIso(), data.path("occurredAt").asText())
|
||||
assertRefundState(fixture, refunded = true)
|
||||
|
||||
val repeated = giftHttp(HttpMethod.POST, cancelPath, adminToken)
|
||||
assertFalse(repeated.path("success").asBoolean())
|
||||
assertEquals("Invalid request.", repeated.path("message").asText())
|
||||
assertTrue(repeated.path("data").isNull)
|
||||
assertEquals(canceledAt, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = true)
|
||||
|
||||
val afterList = giftHttp(HttpMethod.GET, listPath, adminToken)
|
||||
val afterDetail = giftHttp(HttpMethod.GET, detailPath, adminToken)
|
||||
listOf(afterList, afterDetail).forEach { assertTrue(it.path("success").asBoolean()) }
|
||||
assertEquals(1, afterList.path("data").path("totalCount").asInt())
|
||||
listOf(afterList.path("data").path("items").single(), afterDetail.path("data")).forEach { item ->
|
||||
assertEquals(APPLICATION_NO, item.path("applicationNo").asText())
|
||||
assertEquals("CANCELED", item.path("status").asText())
|
||||
assertEquals(emptyList<String>(), item.path("availableActions").map { it.asText() })
|
||||
}
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = GiftStatus::class, names = ["RECEIVED"], mode = EnumSource.Mode.EXCLUDE)
|
||||
@DisplayName("관리자 취소는 모든 비접수 상태를 공통 오류로 거부하고 DB를 변경하지 않는다")
|
||||
fun shouldRejectCancellationForEveryNonReceivedStatus(giftStatus: GiftStatus) {
|
||||
val fixture = saveFundedGift()
|
||||
TransactionTemplate(transactionManager).executeWithoutResult {
|
||||
requireNotNull(giftRepository.findByApplicationNo(APPLICATION_NO)).status = giftStatus
|
||||
}
|
||||
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) }
|
||||
|
||||
val gift = requireNotNull(giftRepository.findByApplicationNo(APPLICATION_NO))
|
||||
assertEquals(giftStatus, gift.status)
|
||||
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 취소는 존재하지 않는 신청번호를 공통 오류로 거부한다")
|
||||
fun shouldRejectCancellationForMissingGift() {
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/missing/cancel").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("미인증 사용자와 일반 사용자 및 크리에이터는 관리자 취소를 실행할 수 없다")
|
||||
fun shouldDenyCancellationWithoutAdminRole() {
|
||||
val fixture = saveFundedGift()
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel"))
|
||||
.andDo(print())
|
||||
.andExpect(status().isUnauthorized)
|
||||
listOf("USER", "CREATOR").forEach { role ->
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with(user("other").roles(role)))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect { assertTrue(it.resolvedException is org.springframework.security.access.AccessDeniedException) }
|
||||
}
|
||||
assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
|
||||
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 환불을 DB에 flush한 뒤 예외가 발생하면 상태와 시각 및 전체 환불 DB 변경이 롤백된다")
|
||||
fun shouldRollbackCancellationAndRealRefundWhenRefundFails() {
|
||||
val fixture = saveFundedGift()
|
||||
Mockito.doAnswer { invocation ->
|
||||
invocation.callRealMethod()
|
||||
entityManager.flush()
|
||||
assertEquals(80, memberRepository.findById(requireNotNull(fixture.sender.id)).orElseThrow().pgRewardCan)
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}.`when`(canPaymentService).refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId)
|
||||
|
||||
mockMvc.perform(cancelRequest(fixture))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
|
||||
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
|
||||
assertRefundState(fixture, refunded = false)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("cancellationRaces")
|
||||
@DisplayName("관리자 취소와 동일 선물 변경은 별도 트랜잭션에서 선행 작업 커밋까지 직렬화된다")
|
||||
fun shouldSerializeCancellationAgainstReceivedMutations(operations: Pair<RaceOperation, RaceOperation>) {
|
||||
val fixture = saveFundedGift()
|
||||
val winnerPaused = CountDownLatch(1)
|
||||
val releaseWinner = CountDownLatch(1)
|
||||
val contenderStarted = CountDownLatch(1)
|
||||
val refundCalls = AtomicInteger()
|
||||
Mockito.doAnswer { invocation ->
|
||||
if (refundCalls.incrementAndGet() == 1 && operations.first != RaceOperation.TRACKING &&
|
||||
operations.first != RaceOperation.REMINDER
|
||||
) {
|
||||
winnerPaused.countDown()
|
||||
assertTrue(releaseWinner.await(10, TimeUnit.SECONDS))
|
||||
}
|
||||
invocation.callRealMethod()
|
||||
}.`when`(canPaymentService).refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId)
|
||||
if (operations.first == RaceOperation.TRACKING) {
|
||||
Mockito.doAnswer {
|
||||
winnerPaused.countDown()
|
||||
assertTrue(releaseWinner.await(10, TimeUnit.SECONDS))
|
||||
null
|
||||
}.`when`(giftPushService).sendTrackingRegisteredToRecipient(APPLICATION_NO, fixture.gift.recipientMemberId)
|
||||
}
|
||||
if (operations.first == RaceOperation.REMINDER) {
|
||||
Mockito.doAnswer {
|
||||
winnerPaused.countDown()
|
||||
assertTrue(releaseWinner.await(10, TimeUnit.SECONDS))
|
||||
null
|
||||
}.`when`(giftPushService).sendTrackingDeadlineReminder(APPLICATION_NO, requireNotNull(fixture.sender.id))
|
||||
}
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
val winner = executor.submit<String> { executeRaceOperation(operations.first, fixture) }
|
||||
var contender: java.util.concurrent.Future<String>? = null
|
||||
try {
|
||||
assertTrue(winnerPaused.await(10, TimeUnit.SECONDS))
|
||||
contender = executor.submit<String> {
|
||||
contenderStarted.countDown()
|
||||
executeRaceOperation(operations.second, fixture)
|
||||
}
|
||||
assertTrue(contenderStarted.await(10, TimeUnit.SECONDS))
|
||||
val waitingContender = contender
|
||||
assertThrows(TimeoutException::class.java) { waitingContender.get(300, TimeUnit.MILLISECONDS) }
|
||||
releaseWinner.countDown()
|
||||
val expectedWinner = when (operations.first) {
|
||||
RaceOperation.TRACKING -> "TRACKING_REGISTERED"
|
||||
RaceOperation.REMINDER -> "REMINDED"
|
||||
else -> "CANCELED"
|
||||
}
|
||||
val expectedContender = when {
|
||||
operations.second in listOf(RaceOperation.AUTO, RaceOperation.REMINDER) -> "SKIPPED"
|
||||
operations.first == RaceOperation.REMINDER -> "CANCELED"
|
||||
else -> "REJECTED"
|
||||
}
|
||||
assertEquals(expectedWinner, winner.get(10, TimeUnit.SECONDS))
|
||||
assertEquals(expectedContender, contender.get(10, TimeUnit.SECONDS))
|
||||
val trackingWon = operations.first == RaceOperation.TRACKING
|
||||
assertEquals(
|
||||
if (trackingWon) GiftStatus.TRACKING_REGISTERED else GiftStatus.CANCELED,
|
||||
giftRepository.findByApplicationNo(APPLICATION_NO)?.status
|
||||
)
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id)))
|
||||
assertEquals(!trackingWon, delivery.canceledAt != null)
|
||||
assertEquals(trackingWon, delivery.trackingRegisteredAt != null)
|
||||
assertEquals(operations.first == RaceOperation.REMINDER, delivery.trackingDeadlineReminderSentAt != null)
|
||||
assertRefundState(fixture, refunded = !trackingWon)
|
||||
Mockito.verify(giftPushService, Mockito.times(if (operations.first == RaceOperation.AUTO) 1 else 0))
|
||||
.sendAutoCanceledToSender(APPLICATION_NO, requireNotNull(fixture.sender.id))
|
||||
} 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 executeRaceOperation(operation: RaceOperation, fixture: FundedGift): String {
|
||||
val requestAttributes = ServletRequestAttributes(MockHttpServletRequest())
|
||||
RequestContextHolder.setRequestAttributes(requestAttributes)
|
||||
return try {
|
||||
when (operation) {
|
||||
RaceOperation.ADMIN -> {
|
||||
val result = mockMvc.perform(cancelRequest(fixture)).andExpect(status().isOk).andReturn()
|
||||
if (objectMapper.readTree(result.response.contentAsByteArray).path("success").asBoolean()) {
|
||||
"CANCELED"
|
||||
} else {
|
||||
assertEquals("common.error.invalid_request", (result.resolvedException as SodaException).messageKey)
|
||||
"REJECTED"
|
||||
}
|
||||
}
|
||||
RaceOperation.USER -> commandService.cancelGift(fixture.sender, APPLICATION_NO).status.name
|
||||
RaceOperation.AUTO -> if (scheduler.cancelExpiredTrackingRegistrationGifts(NOW) == 1) "CANCELED" else "SKIPPED"
|
||||
RaceOperation.REMINDER -> {
|
||||
if (scheduler.sendDeadlineReminderGifts(NOW.minusHours(1)) == 1) "REMINDED" else "SKIPPED"
|
||||
}
|
||||
RaceOperation.TRACKING -> commandService.registerTracking(
|
||||
fixture.sender,
|
||||
APPLICATION_NO,
|
||||
GiftTrackingRegistrationCommand("CJ대한통운", "1234567890")
|
||||
).status.name
|
||||
}
|
||||
} catch (exception: SodaException) {
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
"REJECTED"
|
||||
} finally {
|
||||
requestAttributes.requestCompleted()
|
||||
RequestContextHolder.resetRequestAttributes()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelRequest(fixture: FundedGift) = post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with(
|
||||
authentication(
|
||||
UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(fixture.admin),
|
||||
"test",
|
||||
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private fun tokenFor(member: Member): String = tokenProvider.createToken(
|
||||
UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${member.role.name}"))),
|
||||
requireNotNull(member.id)
|
||||
)
|
||||
|
||||
private fun giftHttp(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
token: String? = null,
|
||||
expectedStatus: HttpStatus = HttpStatus.OK
|
||||
): JsonNode {
|
||||
val headers = HttpHeaders().apply {
|
||||
token?.let { setBearerAuth(it) }
|
||||
set(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
}
|
||||
val response = restTemplate.exchange(
|
||||
"http://127.0.0.1:$port$path",
|
||||
method,
|
||||
HttpEntity<Void>(headers),
|
||||
String::class.java
|
||||
)
|
||||
val body = objectMapper.readTree(response.body.orEmpty().ifBlank { "{}" })
|
||||
val evidence = body.deepCopy<ObjectNode>()
|
||||
val data = evidence.path("data")
|
||||
if (method == HttpMethod.GET && data.isObject) {
|
||||
(data as ObjectNode).retain("applicationNo", "status", "availableActions", "totalCount", "items")
|
||||
data.path("items").forEach { (it as ObjectNode).retain("applicationNo", "status", "availableActions") }
|
||||
}
|
||||
println("GIFT_HTTP $method $path status=${response.statusCodeValue} body=$evidence")
|
||||
assertEquals(expectedStatus, response.statusCode)
|
||||
return body
|
||||
}
|
||||
|
||||
private fun saveFundedGift(): FundedGift = requireNotNull(
|
||||
TransactionTemplate(transactionManager).execute {
|
||||
val sender = memberRepository.save(
|
||||
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "funded-sender").apply {
|
||||
pgChargeCan = 100
|
||||
pgRewardCan = 20
|
||||
}
|
||||
)
|
||||
val admin = memberRepository.save(
|
||||
Member(
|
||||
email = "${UUID.randomUUID()}@test.com",
|
||||
password = "password",
|
||||
nickname = "cancel-admin",
|
||||
role = MemberRole.ADMIN
|
||||
).apply {
|
||||
pgChargeCan = 50
|
||||
pgRewardCan = 7
|
||||
}
|
||||
)
|
||||
val funding = chargeRepository.save(
|
||||
Charge(100, 20).apply {
|
||||
member = sender
|
||||
payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
}
|
||||
)
|
||||
val usageId = canPaymentService.spendGiftCan(requireNotNull(sender.id), 80, "web")
|
||||
val category = categoryRepository.save(
|
||||
GiftCategory(
|
||||
classificationNumber = "100",
|
||||
categoryCode = "FUNDED",
|
||||
name = "인형",
|
||||
receiptCode = "A",
|
||||
representativeItem = "피규어"
|
||||
)
|
||||
)
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = requireNotNull(sender.id),
|
||||
recipientMemberId = requireNotNull(admin.id),
|
||||
status = GiftStatus.RECEIVED,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = requireNotNull(category.id),
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = usageId,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW.minusDays(4),
|
||||
senderPrivacyAgreedAt = NOW.minusDays(4)
|
||||
)
|
||||
)
|
||||
giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = requireNotNull(gift.id),
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.minusSeconds(1)
|
||||
)
|
||||
)
|
||||
FundedGift(gift, sender, admin, requireNotNull(funding.id))
|
||||
}
|
||||
)
|
||||
|
||||
private fun assertRefundState(fixture: FundedGift, refunded: Boolean) {
|
||||
TransactionTemplate(transactionManager).executeWithoutResult {
|
||||
val sender = memberRepository.findById(requireNotNull(fixture.sender.id)).orElseThrow()
|
||||
val admin = memberRepository.findById(requireNotNull(fixture.admin.id)).orElseThrow()
|
||||
assertEquals(40, sender.pgChargeCan)
|
||||
assertEquals(if (refunded) 80 else 0, sender.pgRewardCan)
|
||||
assertEquals(50, admin.pgChargeCan)
|
||||
assertEquals(7, admin.pgRewardCan)
|
||||
val usage = useCanRepository.findById(fixture.gift.canUsageId).orElseThrow()
|
||||
assertEquals(CanUsage.GIFT, usage.canUsage)
|
||||
assertEquals(60, usage.can)
|
||||
assertEquals(20, usage.rewardCan)
|
||||
assertEquals(fixture.sender.id, usage.member?.id)
|
||||
assertEquals(refunded, usage.isRefund)
|
||||
val calculations = calculateRepository.findByUseCanIdAndStatus(
|
||||
fixture.gift.canUsageId,
|
||||
if (refunded) UseCanCalculateStatus.REFUND else UseCanCalculateStatus.RECEIVED
|
||||
)
|
||||
assertEquals(1, calculations.size)
|
||||
assertEquals(80, calculations.sumOf { it.can })
|
||||
val refunds = chargeRepository.findAll().filter { it.useCan?.id == fixture.gift.canUsageId }
|
||||
assertEquals(if (refunded) 1 else 0, refunds.size)
|
||||
assertEquals(if (refunded) 80 else 0, refunds.sumOf { it.rewardCan })
|
||||
refunds.forEach {
|
||||
assertEquals(ChargeStatus.REFUND_CHARGE, it.status)
|
||||
assertEquals(0, it.chargeCan)
|
||||
assertEquals(fixture.sender.id, it.member?.id)
|
||||
assertEquals(PaymentGateway.PG, it.payment?.paymentGateway)
|
||||
assertEquals(PaymentStatus.COMPLETE, it.payment?.status)
|
||||
}
|
||||
val funding = chargeRepository.findById(fixture.fundingId).orElseThrow()
|
||||
assertEquals(40, funding.chargeCan)
|
||||
assertEquals(0, funding.rewardCan)
|
||||
}
|
||||
}
|
||||
|
||||
private data class FundedGift(val gift: Gift, val sender: Member, val admin: Member, val fundingId: Long)
|
||||
|
||||
enum class RaceOperation { ADMIN, USER, AUTO, TRACKING, REMINDER }
|
||||
|
||||
private fun saveGift(status: GiftStatus, withRecipientAddress: Boolean = false) {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
@@ -445,6 +971,19 @@ class AdminGiftControllerTest @Autowired constructor(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
@JvmStatic
|
||||
fun cancellationRaces() = listOf(
|
||||
RaceOperation.ADMIN to RaceOperation.ADMIN,
|
||||
RaceOperation.ADMIN to RaceOperation.USER,
|
||||
RaceOperation.USER to RaceOperation.ADMIN,
|
||||
RaceOperation.ADMIN to RaceOperation.AUTO,
|
||||
RaceOperation.AUTO to RaceOperation.ADMIN,
|
||||
RaceOperation.ADMIN to RaceOperation.TRACKING,
|
||||
RaceOperation.TRACKING to RaceOperation.ADMIN,
|
||||
RaceOperation.ADMIN to RaceOperation.REMINDER,
|
||||
RaceOperation.REMINDER to RaceOperation.ADMIN
|
||||
)
|
||||
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
const val SENDER_MEMBER_ID = 1L
|
||||
const val RECIPIENT_MEMBER_ID = 2L
|
||||
|
||||
Reference in New Issue
Block a user