From 30a2f7e37b460439a237d763e49c6ea49388a2b0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 22 Oct 2026 18:24:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20API=EC=99=80=20=ED=99=98=EB=B6=88=20?= =?UTF-8?q?=EA=B2=BD=ED=95=A9=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/in/web/AdminGiftController.kt | 5 + .../adapter/in/web/AdminGiftControllerTest.kt | 541 +++++++++++++++++- 2 files changed, 545 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt index be9c88d1..bc710a34 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt @@ -83,6 +83,11 @@ class AdminGiftController( @RequestBody request: AdminGiftSizePriceRequest ) = 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") fun arriveMailbox(@PathVariable applicationNo: String) = ApiResponse.ok( AdminGiftOperationStatusResponse.from(service.arriveMailbox(applicationNo)) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt index 98064c95..ae27c462 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt @@ -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(), 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) { + 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 { executeRaceOperation(operations.first, fixture) } + var contender: java.util.concurrent.Future? = null + try { + assertTrue(winnerPaused.await(10, TimeUnit.SECONDS)) + contender = executor.submit { + 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(headers), + String::class.java + ) + val body = objectMapper.readTree(response.body.orEmpty().ifBlank { "{}" }) + val evidence = body.deepCopy() + 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