feat(gift): 선물 사용자 API를 추가한다

This commit is contained in:
2026-09-30 13:39:04 +09:00
parent 08347f9ab5
commit 6a2ae38483
2 changed files with 581 additions and 0 deletions
@@ -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()
)
)
)
}
@@ -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"
}
}