feat(gift): 관리자 취소 API와 환불 경합 검증을 추가한다
This commit is contained in:
+5
@@ -83,6 +83,11 @@ class AdminGiftController(
|
|||||||
@RequestBody request: AdminGiftSizePriceRequest
|
@RequestBody request: AdminGiftSizePriceRequest
|
||||||
) = ApiResponse.ok(AdminGiftSizePriceResponse.from(service.updateSizePrice(sizeCode, request.toCommand())))
|
) = ApiResponse.ok(AdminGiftSizePriceResponse.from(service.updateSizePrice(sizeCode, request.toCommand())))
|
||||||
|
|
||||||
|
@PostMapping("/gifts/{applicationNo}/cancel")
|
||||||
|
fun cancelGift(@PathVariable applicationNo: String) = ApiResponse.ok(
|
||||||
|
AdminGiftOperationStatusResponse.from(service.cancelGift(applicationNo))
|
||||||
|
)
|
||||||
|
|
||||||
@PostMapping("/gifts/{applicationNo}/arrive-mailbox")
|
@PostMapping("/gifts/{applicationNo}/arrive-mailbox")
|
||||||
fun arriveMailbox(@PathVariable applicationNo: String) = ApiResponse.ok(
|
fun arriveMailbox(@PathVariable applicationNo: String) = ApiResponse.ok(
|
||||||
AdminGiftOperationStatusResponse.from(service.arriveMailbox(applicationNo))
|
AdminGiftOperationStatusResponse.from(service.arriveMailbox(applicationNo))
|
||||||
|
|||||||
+540
-1
@@ -1,7 +1,26 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
|
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.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
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.support.EmbeddedRedisInitializer
|
||||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
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.GiftRepository
|
||||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
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.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.GiftSize
|
||||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||||
import org.hamcrest.Matchers.endsWith
|
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.BeforeEach
|
||||||
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.provider.EnumSource
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
|
import org.mockito.Mockito
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
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.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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
import org.springframework.test.annotation.DirtiesContext
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
import org.springframework.test.context.ContextConfiguration
|
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.get
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
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.jsonPath
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
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.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(
|
@SpringBootTest(
|
||||||
|
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
properties = [
|
properties = [
|
||||||
"cloud.aws.cloud-front.host=https://cdn.test",
|
"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;" +
|
"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 sizePriceRepository: GiftSizePriceRepository,
|
||||||
private val giftRepository: GiftRepository,
|
private val giftRepository: GiftRepository,
|
||||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
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
|
@BeforeEach
|
||||||
fun deleteGiftFixtures() {
|
fun deleteGiftFixtures() {
|
||||||
|
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest()))
|
||||||
giftDeliveryRepository.deleteAll()
|
giftDeliveryRepository.deleteAll()
|
||||||
giftRepository.deleteAll()
|
giftRepository.deleteAll()
|
||||||
categoryRepository.deleteAll()
|
categoryRepository.deleteAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun clearRequestContext() {
|
||||||
|
(RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.requestCompleted()
|
||||||
|
RequestContextHolder.resetRequestAttributes()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("관리자는 받을 주소를 조회하고 저장한다")
|
@DisplayName("관리자는 받을 주소를 조회하고 저장한다")
|
||||||
fun shouldManageGiftMailbox() {
|
fun shouldManageGiftMailbox() {
|
||||||
@@ -352,6 +436,448 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.success").value(false))
|
.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) {
|
private fun saveGift(status: GiftStatus, withRecipientAddress: Boolean = false) {
|
||||||
val gift = giftRepository.save(
|
val gift = giftRepository.save(
|
||||||
Gift(
|
Gift(
|
||||||
@@ -445,6 +971,19 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
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 APPLICATION_NO = "G20260929000001"
|
||||||
const val SENDER_MEMBER_ID = 1L
|
const val SENDER_MEMBER_ID = 1L
|
||||||
const val RECIPIENT_MEMBER_ID = 2L
|
const val RECIPIENT_MEMBER_ID = 2L
|
||||||
|
|||||||
Reference in New Issue
Block a user