Test #459

Merged
klaus merged 8 commits from test into main 2026-10-22 11:21:33 +00:00
2 changed files with 545 additions and 1 deletions
Showing only changes of commit 30a2f7e37b - Show all commits
@@ -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))
@@ -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