feat(gift): 선물 관리자 API를 추가한다

This commit is contained in:
2026-09-30 13:39:44 +09:00
parent 6f9dcaa1e2
commit d360db6f08
2 changed files with 298 additions and 0 deletions
@@ -0,0 +1,74 @@
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceResponse
import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/admin")
@PreAuthorize("hasRole('ADMIN')")
class AdminGiftController(private val service: GiftAdminService) {
@GetMapping("/gift-categories")
fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) })
@PostMapping("/gift-categories")
fun createCategory(@RequestBody request: AdminGiftCategoryRequest) = ApiResponse.ok(
AdminGiftCategoryResponse.from(service.createCategory(request.toCommand()))
)
@PutMapping("/gift-categories/{categoryId}")
fun updateCategory(
@PathVariable categoryId: Long,
@RequestBody request: AdminGiftCategoryRequest
) = ApiResponse.ok(AdminGiftCategoryResponse.from(service.updateCategory(categoryId, request.toCommand())))
@DeleteMapping("/gift-categories/{categoryId}")
fun deleteCategory(@PathVariable categoryId: Long) = ApiResponse.ok(
AdminGiftCategoryResponse.from(service.deleteCategory(categoryId))
)
@GetMapping("/gift-size-prices")
fun getSizePrices() = ApiResponse.ok(service.getSizePrices().map { AdminGiftSizePriceResponse.from(it) })
@PutMapping("/gift-size-prices/{sizeCode}")
fun updateSizePrice(
@PathVariable sizeCode: GiftSize,
@RequestBody request: AdminGiftSizePriceRequest
) = ApiResponse.ok(AdminGiftSizePriceResponse.from(service.updateSizePrice(sizeCode, request.toCommand())))
@PostMapping("/gifts/{applicationNo}/arrive-mailbox")
fun arriveMailbox(@PathVariable applicationNo: String) = ApiResponse.ok(
AdminGiftOperationStatusResponse.from(service.arriveMailbox(applicationNo))
)
@PostMapping("/gifts/{applicationNo}/complete-inspection")
fun completeInspection(@PathVariable applicationNo: String) = ApiResponse.ok(
AdminGiftOperationStatusResponse.from(service.completeInspection(applicationNo))
)
@PostMapping("/gifts/{applicationNo}/mark-undeliverable")
fun markUndeliverable(
@PathVariable applicationNo: String,
@RequestBody request: AdminGiftMarkUndeliverableRequest
) = ApiResponse.ok(AdminGiftOperationStatusResponse.from(service.markUndeliverable(applicationNo, request.reason)))
@PostMapping("/gifts/{applicationNo}/complete-delivery")
fun completeDelivery(@PathVariable applicationNo: String) = ApiResponse.ok(
AdminGiftOperationStatusResponse.from(service.completeDelivery(applicationNo))
)
}
@@ -0,0 +1,224 @@
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
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.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
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.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
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.http.MediaType
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
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.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.time.LocalDateTime
@SpringBootTest(
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;" +
"DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@DisplayName("선물 관리자 API 테스트")
class AdminGiftControllerTest @Autowired constructor(
private val mockMvc: MockMvc,
private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository
) {
@BeforeEach
fun deleteGiftFixtures() {
giftDeliveryRepository.deleteAll()
giftRepository.deleteAll()
}
@Test
@DisplayName("관리자는 카테고리를 등록, 조회, 수정, 논리삭제한다")
fun shouldManageGiftCategories() {
mockMvc.perform(
post("/api/v2/admin/gift-categories")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{"categoryCode":"DOLL","name":"인형","requiresDamageWaiver":true,"isActive":true}
""".trimIndent()
)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.categoryCode").value("DOLL"))
val categoryId = categoryRepository.findByCategoryCode("DOLL")!!.id!!
mockMvc.perform(
put("/api/v2/admin/gift-categories/$categoryId")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{"categoryCode":"DOLL","name":"큰 인형","requiresDamageWaiver":false,"isActive":true}
""".trimIndent()
)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.name").value("큰 인형"))
mockMvc.perform(get("/api/v2/admin/gift-categories").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data[0].name").value("큰 인형"))
mockMvc.perform(delete("/api/v2/admin/gift-categories/$categoryId").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.isActive").value(false))
}
@Test
@DisplayName("관리자는 사이즈 가격을 조회하고 수정하며 잘못된 가격을 거부한다")
fun shouldManageGiftSizePrices() {
sizePriceRepository.save(GiftSizePrice(sizeCode = GiftSize.SMALL, basePriceCan = 100, salePriceCan = 100))
mockMvc.perform(get("/api/v2/admin/gift-size-prices").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data[0].sizeCode").value("SMALL"))
mockMvc.perform(
put("/api/v2/admin/gift-size-prices/SMALL")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"basePriceCan":100,"salePriceCan":80,"isActive":true}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.salePriceCan").value(80))
mockMvc.perform(
put("/api/v2/admin/gift-size-prices/SMALL")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"basePriceCan":100,"salePriceCan":101,"isActive":true}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("관리자는 사서함 도착과 검수완료를 처리한다")
fun shouldUpdateGiftOperationStatuses() {
saveGift(GiftStatus.TRACKING_REGISTERED)
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/arrive-mailbox").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
.andExpect(jsonPath("$.data.status").value("ARRIVED_AT_MAILBOX"))
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-inspection").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("INSPECTION_COMPLETED"))
}
@Test
@DisplayName("관리자는 전달불가 사유를 등록하고 전달불가 처리한다")
fun shouldMarkGiftUndeliverable() {
saveGift(GiftStatus.INSPECTION_COMPLETED)
mockMvc.perform(
post("/api/v2/admin/gifts/$APPLICATION_NO/mark-undeliverable")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"reason":"선물 접수 규격에 맞지 않습니다."}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
.andExpect(jsonPath("$.data.status").value("UNDELIVERABLE"))
}
@Test
@DisplayName("관리자는 전달불가 사유 없이 전달불가 처리할 수 없다")
fun shouldRejectUndeliverableWithoutReason() {
saveGift(GiftStatus.INSPECTION_COMPLETED)
mockMvc.perform(
post("/api/v2/admin/gifts/$APPLICATION_NO/mark-undeliverable")
.with(user("admin").roles("ADMIN"))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"reason":" "}""")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("관리자는 검수완료 선물을 전달완료 처리한다")
fun shouldCompleteGiftDelivery() {
saveGift(GiftStatus.INSPECTION_COMPLETED)
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-delivery").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
.andExpect(jsonPath("$.data.status").value("DELIVERED"))
}
private fun saveGift(status: GiftStatus) {
val gift = giftRepository.save(
Gift(
applicationNo = APPLICATION_NO,
senderMemberId = SENDER_MEMBER_ID,
recipientMemberId = RECIPIENT_MEMBER_ID,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = 1L,
categoryNameSnapshot = "인형",
salePriceCan = 80,
canUsageId = 10L,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW.minusDays(1),
senderPrivacyAgreedAt = NOW.minusDays(1),
damageWaiverAgreed = true
)
)
giftDeliveryRepository.save(
GiftDelivery(
giftId = requireNotNull(gift.id),
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
trackingDeadlineAt = NOW.minusDays(1),
trackingRegisteredAt = NOW.minusHours(1)
)
)
}
private companion object {
const val APPLICATION_NO = "G20260929000001"
const val SENDER_MEMBER_ID = 1L
const val RECIPIENT_MEMBER_ID = 2L
val NOW: LocalDateTime = LocalDateTime.of(2026, 10, 3, 12, 0)
}
}