From 6a2ae3848366bdb9b4c43bc60f6e35e08d64ffe9 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:39:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/gift/adapter/in/web/GiftController.kt | 153 +++++++ .../gift/adapter/in/web/GiftControllerTest.kt | 428 ++++++++++++++++++ 2 files changed, 581 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt new file mode 100644 index 00000000..17826e91 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt @@ -0,0 +1,153 @@ +package kr.co.vividnext.sodalive.v2.api.gift.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftCancellationResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftDeliveryConfirmationResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftDetailResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftFormOptionsResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftListResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationResponse +import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService +import kr.co.vividnext.sodalive.v2.gift.application.GiftQueryService +import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType +import org.springframework.security.core.annotation.AuthenticationPrincipal +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.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/gifts") +class GiftController( + private val queryService: GiftQueryService, + private val commandService: GiftCommandService +) { + @GetMapping("/form-options") + fun getFormOptions() = ApiResponse.ok(GiftFormOptionsResponse.from(queryService.getFormOptions())) + + @GetMapping + fun getGifts( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestParam(defaultValue = "ALL") type: GiftListType, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ) = ApiResponse.ok( + GiftListResponse.from( + queryService.getGifts( + memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"), + type = type, + page = page, + size = size + ) + ) + ) + + @GetMapping("/{applicationNo}") + fun getGiftDetail( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String + ) = ApiResponse.ok( + GiftDetailResponse.from( + queryService.getGiftDetail( + memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo + ) + ) + ) + + @PostMapping + fun registerGift( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestBody request: GiftApplicationRequest + ) = ApiResponse.ok( + GiftApplicationResponse.from( + commandService.registerGift( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + command = request.toCommand() + ) + ) + ) + + @PostMapping("/{applicationNo}/cancel") + fun cancelGift( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String + ) = ApiResponse.ok( + GiftCancellationResponse.from( + commandService.cancelGift( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo + ) + ) + ) + + @PostMapping("/{applicationNo}/tracking") + fun registerTracking( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String, + @RequestBody request: GiftTrackingRegistrationRequest + ) = ApiResponse.ok( + GiftTrackingRegistrationResponse.from( + commandService.registerTracking( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo, + command = request.toCommand() + ) + ) + ) + + @PostMapping("/{applicationNo}/recipient-address") + fun registerRecipientAddress( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String, + @RequestBody request: GiftRecipientAddressRegistrationRequest + ) = ApiResponse.ok( + GiftRecipientAddressRegistrationResponse.from( + commandService.registerRecipientAddress( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo, + command = request.toCommand() + ) + ) + ) + + @PostMapping("/{applicationNo}/delivery-complete") + fun confirmDelivery( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String + ) = ApiResponse.ok( + GiftDeliveryConfirmationResponse.from( + commandService.confirmDelivery( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo + ) + ) + ) + + @PostMapping("/{applicationNo}/review") + fun writeReview( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String, + @RequestBody request: GiftReviewRequest + ) = ApiResponse.ok( + GiftReviewResponse.from( + commandService.writeReview( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo, + command = request.toCommand() + ) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt new file mode 100644 index 00000000..effe8e04 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt @@ -0,0 +1,428 @@ +package kr.co.vividnext.sodalive.v2.api.gift.adapter.`in`.web + +import com.fasterxml.jackson.databind.ObjectMapper +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.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationRequest +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.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.application.GiftApplicationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService +import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +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.http.MediaType +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.LocalDateTime + +@SpringBootTest( + properties = [ + "spring.datasource.url=jdbc:h2:mem:gift-controller;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class GiftControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val categoryRepository: GiftCategoryRepository, + private val giftRepository: GiftRepository, + private val giftDeliveryRepository: GiftDeliveryRepository, + private val sizePriceRepository: GiftSizePriceRepository, + private val memberRepository: MemberRepository, + private val objectMapper: ObjectMapper +) { + @MockBean + private lateinit var commandService: GiftCommandService + + @Test + fun `팬은 활성 폼 옵션을 조회하고 categoryCode를 받지 않는다`() { + categoryRepository.save(GiftCategory("DOLL", "인형", true, true)) + categoryRepository.save(GiftCategory("FOOD", "간식", false, false)) + sizePriceRepository.save(GiftSizePrice(GiftSize.SMALL, 100, 80, true)) + + mockMvc.perform(get("/api/v2/gifts/form-options").with(user("fan").roles("USER"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.sizes[0].sizeCode").value("SMALL")) + .andExpect(jsonPath("$.data.sizes[0].name").value("소형")) + .andExpect(jsonPath("$.data.sizes[0].basePriceCan").value(100)) + .andExpect(jsonPath("$.data.sizes[0].salePriceCan").value(80)) + .andExpect(jsonPath("$.data.categories[0].name").value("인형")) + .andExpect(jsonPath("$.data.categories[0].categoryCode").doesNotExist()) + } + + @Test + @DisplayName("회원은 선물함 목록에서 보낸 선물과 노출 가능한 받은 선물을 페이징 응답으로 조회한다") + fun shouldListGifts() { + val member = Member(password = "password", nickname = "member").apply { id = 10L } + saveGift("G-CONTROLLER-SENT", senderMemberId = 10L, recipientMemberId = 20L, status = GiftStatus.RECEIVED) + saveGift("G-CONTROLLER-HIDDEN", senderMemberId = 20L, recipientMemberId = 10L, status = GiftStatus.RECEIVED) + saveGift( + "G-CONTROLLER-VISIBLE", + senderMemberId = 20L, + recipientMemberId = 10L, + status = GiftStatus.TRACKING_REGISTERED + ) + + mockMvc.perform( + get("/api/v2/gifts") + .param("type", "ALL") + .param("page", "-1") + .param("size", "0") + .with(user(MemberAdapter(member))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.items.length()").value(2)) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.items[0].applicationNo").value("G-CONTROLLER-VISIBLE")) + .andExpect(jsonPath("$.data.items[0].direction").value("RECEIVED")) + .andExpect(jsonPath("$.data.items[0].status").value("TRACKING_REGISTERED")) + .andExpect(jsonPath("$.data.items[0].statusName").value("발송 확인")) + .andExpect(jsonPath("$.data.items[0].priceCan").value(80)) + .andExpect(jsonPath("$.data.items[0].categoryName").value("인형")) + .andExpect(jsonPath("$.data.items[0].sizeName").value("소형")) + .andExpect(jsonPath("$.data.items[0].createdAt").exists()) + } + + @Test + @DisplayName("발신자는 인증 정보로 선물 상세와 자신의 발신자 정보를 조회한다") + fun shouldGetSentGiftDetail() { + val sender = memberRepository.save(Member(password = "password", nickname = "fan")) + val recipient = memberRepository.save(Member(password = "password", nickname = "creator")) + val gift = saveGift( + applicationNo = "G-CONTROLLER-DETAIL", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.TRACKING_REGISTERED + ) + giftDeliveryRepository.save( + GiftDelivery( + giftId = gift.id!!, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + senderAddressDetail = "101동 1001호", + trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0) + ).apply { + courierCompanyName = "CJ대한통운" + trackingNumber = "1234567890" + trackingRegisteredAt = LocalDateTime.of(2026, 9, 30, 12, 0) + } + ) + + mockMvc.perform( + get("/api/v2/gifts/{applicationNo}", gift.applicationNo) + .with(user(MemberAdapter(sender))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value("G-CONTROLLER-DETAIL")) + .andExpect(jsonPath("$.data.direction").value("SENT")) + .andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED")) + .andExpect(jsonPath("$.data.giftInfo.recipientCreatorNickname").value("creator")) + .andExpect(jsonPath("$.data.giftInfo.applicationNo").value("G-CONTROLLER-DETAIL")) + .andExpect(jsonPath("$.data.giftInfo.paidCan").value(80)) + .andExpect(jsonPath("$.data.giftInfo.tracking").value("CJ대한통운_1234567890")) + .andExpect(jsonPath("$.data.senderInfo.name").value("홍길동")) + .andExpect(jsonPath("$.data.senderInfo.address").value("(06234) 서울시 강남구, 101동 1001호")) + .andExpect(jsonPath("$.data.recipientAddress").isEmpty) + .andExpect(jsonPath("$.data.trackingRequired").value(false)) + .andExpect(jsonPath("$.data.statusTimeline[0].status").value("RECEIVED")) + .andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00")) + } + + @Test + @DisplayName("팬은 POST /api/v2/gifts로 선물을 신청하고 접수 결과를 받는다") + fun shouldRegisterGift() { + // Given: 로그인한 팬과 선물 신청 요청을 준비한다. + val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID } + val request = GiftApplicationRequest( + recipientMemberId = RECIPIENT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + senderAddressDetail = "101동 1001호", + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + damageWaiverAgreed = true + ) + val deadline = LocalDateTime.of(2026, 10, 2, 12, 0) + Mockito.`when`( + commandService.registerGift(member, request.toCommand()) + ).thenReturn( + GiftApplicationResult( + applicationNo = "G20260929000001", + status = GiftStatus.RECEIVED, + statusName = "접수 완료", + priceCan = 80, + trackingDeadlineAt = deadline + ) + ) + + // When: 팬이 선물 신청 API를 호출한다. + mockMvc.perform( + post("/api/v2/gifts") + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + // Then: 신청번호, 접수 상태, 판매가와 운송장 등록 기한을 반환한다. + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value("G20260929000001")) + .andExpect(jsonPath("$.data.status").value("RECEIVED")) + .andExpect(jsonPath("$.data.statusName").value("접수 완료")) + .andExpect(jsonPath("$.data.priceCan").value(80)) + .andExpect(jsonPath("$.data.trackingDeadlineAt").value("2026-10-02T12:00:00")) + } + + @Test + @DisplayName("팬은 POST /api/v2/gifts/{applicationNo}/cancel로 접수된 선물을 취소하고 환불 결과를 받는다") + fun shouldCancelGift() { + // Given: 취소할 접수 선물과 로그인한 발신자를 준비한다. + val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID } + val canceledAt = LocalDateTime.of(2026, 9, 29, 12, 0) + Mockito.`when`(commandService.cancelGift(member, APPLICATION_NO)).thenReturn( + GiftCancellationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.CANCELED, + statusName = "신청 취소", + priceCan = 80, + canceledAt = canceledAt + ) + ) + + // When: 발신자 인증으로 취소 API를 호출한다. + mockMvc.perform( + post("/api/v2/gifts/{applicationNo}/cancel", APPLICATION_NO) + .with(user(MemberAdapter(member))) + ) + // Then: 취소 상태, 환불 금액과 취소 시각을 반환한다. + .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.priceCan").value(80)) + .andExpect(jsonPath("$.data.canceledAt").value("2026-09-29T12:00:00")) + } + + @Test + @DisplayName("팬은 POST /api/v2/gifts/{applicationNo}/tracking으로 운송장을 등록하고 발송 확인 결과를 받는다") + fun shouldRegisterTracking() { + // Given: 운송장 등록 요청과 로그인한 발신자를 준비한다. + val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID } + val request = GiftTrackingRegistrationRequest( + courierCompanyName = "CJ대한통운", + trackingNumber = "1234567890" + ) + val trackingRegisteredAt = LocalDateTime.of(2026, 9, 29, 12, 0) + val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7) + Mockito.`when`(commandService.registerTracking(member, APPLICATION_NO, request.toCommand())).thenReturn( + GiftTrackingRegistrationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.TRACKING_REGISTERED, + statusName = "발송 확인", + courierCompanyName = "CJ대한통운", + trackingNumber = "1234567890", + trackingRegisteredAt = trackingRegisteredAt, + recipientAddressDeadlineAt = recipientAddressDeadlineAt + ) + ) + + // When: 발신자 인증으로 운송장 등록 API를 호출한다. + mockMvc.perform( + post("/api/v2/gifts/{applicationNo}/tracking", APPLICATION_NO) + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + // Then: 신청번호, 발송 확인 상태와 운송장 및 수신자 주소 입력 기한을 반환한다. + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED")) + .andExpect(jsonPath("$.data.statusName").value("발송 확인")) + .andExpect(jsonPath("$.data.courierCompanyName").value("CJ대한통운")) + .andExpect(jsonPath("$.data.trackingNumber").value("1234567890")) + .andExpect(jsonPath("$.data.trackingRegisteredAt").value("2026-09-29T12:00:00")) + .andExpect(jsonPath("$.data.recipientAddressDeadlineAt").value("2026-10-06T12:00:00")) + } + + @Test + @DisplayName("받는 크리에이터는 POST recipient-address로 주소 등록 성공 envelope를 받는다") + fun shouldRegisterRecipientAddress() { + // Given: 로그인한 받는 크리에이터와 주소 등록 요청을 준비한다. + val member = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID } + val request = GiftRecipientAddressRegistrationRequest( + recipientName = "김소다", + recipientPhoneNumber = "01098765432", + recipientZipCode = "04524", + recipientAddress = "서울시 중구", + recipientAddressDetail = "202호", + recipientTermsAgreed = true, + recipientPrivacyAgreed = true + ) + val registeredAt = LocalDateTime.of(2026, 9, 29, 12, 0) + Mockito.`when`( + commandService.registerRecipientAddress(member, APPLICATION_NO, request.toCommand()) + ).thenReturn( + GiftRecipientAddressRegistrationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.TRACKING_REGISTERED, + statusName = "발송 확인", + recipientAddressRegisteredAt = registeredAt + ) + ) + + // When: 받는 크리에이터 인증으로 주소 등록 API를 호출한다. + mockMvc.perform( + post("/api/v2/gifts/{applicationNo}/recipient-address", APPLICATION_NO) + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + // Then: 성공 envelope에 신청번호, 상태와 주소 등록 시각을 반환한다. + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED")) + .andExpect(jsonPath("$.data.statusName").value("발송 확인")) + .andExpect(jsonPath("$.data.recipientAddressRegisteredAt").value("2026-09-29T12:00:00")) + } + + @Test + @DisplayName("받는 크리에이터는 POST delivery-complete로 수령 확인 성공 envelope를 받는다") + fun shouldConfirmDelivery() { + val member = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID } + val deliveredAt = LocalDateTime.of(2026, 9, 29, 12, 0) + Mockito.`when`(commandService.confirmDelivery(member, APPLICATION_NO)).thenReturn( + GiftDeliveryConfirmationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.DELIVERED, + statusName = "전달완료", + deliveredAt = deliveredAt + ) + ) + + mockMvc.perform( + post("/api/v2/gifts/{applicationNo}/delivery-complete", APPLICATION_NO) + .with(user(MemberAdapter(member))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.status").value("DELIVERED")) + .andExpect(jsonPath("$.data.statusName").value("전달완료")) + .andExpect(jsonPath("$.data.deliveredAt").value("2026-09-29T12:00:00")) + + Mockito.verify(commandService).confirmDelivery(member, APPLICATION_NO) + } + + @Test + @DisplayName("팬은 POST review로 리뷰를 작성하고 keywords 배열 응답을 받는다") + fun shouldWriteReview() { + val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID } + val request = GiftReviewRequest( + rating = 5, + keywords = listOf("배송이 빨라요", "안내가 친절해요"), + comment = "선물 전달 과정이 만족스러웠습니다." + ) + val createdAt = LocalDateTime.of(2026, 10, 11, 12, 0) + Mockito.`when`(commandService.writeReview(member, APPLICATION_NO, request.toCommand())).thenReturn( + GiftReviewResult( + reviewId = 1L, + applicationNo = APPLICATION_NO, + rating = 5, + keywords = listOf("배송이 빨라요", "안내가 친절해요"), + comment = "선물 전달 과정이 만족스러웠습니다.", + createdAt = createdAt + ) + ) + + mockMvc.perform( + post("/api/v2/gifts/{applicationNo}/review", APPLICATION_NO) + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.reviewId").value(1L)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.rating").value(5)) + .andExpect(jsonPath("$.data.keywords[0]").value("배송이 빨라요")) + .andExpect(jsonPath("$.data.keywords[1]").value("안내가 친절해요")) + .andExpect(jsonPath("$.data.keyword").doesNotExist()) + .andExpect(jsonPath("$.data.comment").value("선물 전달 과정이 만족스러웠습니다.")) + .andExpect(jsonPath("$.data.createdAt").value("2026-10-11T12:00:00")) + + Mockito.verify(commandService).writeReview(member, APPLICATION_NO, request.toCommand()) + } + + private fun saveGift( + applicationNo: String, + senderMemberId: Long, + recipientMemberId: Long, + status: GiftStatus + ) = giftRepository.save( + Gift( + applicationNo = applicationNo, + senderMemberId = senderMemberId, + recipientMemberId = recipientMemberId, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = 80, + canUsageId = 1L, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0), + senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0) + ) + ) + + private companion object { + const val SENDER_ID = 1L + const val RECIPIENT_ID = 2L + const val CATEGORY_ID = 3L + const val APPLICATION_NO = "G20260929000001" + } +}