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 new file mode 100644 index 00000000..3de06d87 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt @@ -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)) + ) +} 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 new file mode 100644 index 00000000..38bd8172 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt @@ -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) + } +}