feat(gift): 선물 조회 서비스를 추가한다

This commit is contained in:
2026-09-30 13:37:26 +09:00
parent 95b3b16897
commit fcb1c1e8d1
2 changed files with 667 additions and 0 deletions
@@ -0,0 +1,259 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.MemberRepository
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.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection
import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
data class GiftFormOptionsResult(
val sizes: List<GiftSizePriceResult>,
val categories: List<GiftCategoryResult>
)
data class GiftListResult(
val totalCount: Long,
val items: List<GiftListItemResult>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class GiftListItemResult(
val applicationNo: String,
val direction: GiftDirection,
val status: GiftStatus,
val statusName: String,
val priceCan: Int,
val categoryName: String,
val sizeName: String,
val createdAt: LocalDateTime?
)
data class GiftDetailResult(
val applicationNo: String,
val direction: GiftDirection,
val status: GiftStatus,
val statusName: String,
val giftInfo: GiftDetailInfoResult,
val senderInfo: GiftAddressResult?,
val recipientAddress: GiftAddressResult?,
val mailbox: GiftMailboxResult?,
val trackingRequired: Boolean,
val recipientAddressRequired: Boolean,
val recipientAddressDeadlineAt: LocalDateTime?,
val delivery: GiftDeliveryInfoResult,
val statusTimeline: List<GiftStatusTimelineResult>
)
data class GiftDetailInfoResult(
val recipientCreatorNickname: String?,
val senderNickname: String?,
val sizeName: String,
val categoryName: String,
val applicationNo: String?,
val paidCan: Int?,
val tracking: String?,
val shippingRequestedAt: LocalDateTime?
)
data class GiftAddressResult(
val name: String,
val phoneNumber: String,
val address: String
)
data class GiftMailboxResult(
val name: String,
val address: String,
val phoneNumber: String
)
data class GiftDeliveryInfoResult(
val canceledAt: LocalDateTime?,
val undeliverableAt: LocalDateTime?,
val undeliverableReason: String?
)
data class GiftStatusTimelineResult(
val status: GiftStatus,
val statusName: String,
val occurredAt: LocalDateTime?
)
@Service
class GiftQueryService(
private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository,
private val memberRepository: MemberRepository
) {
@Transactional(readOnly = true)
fun getFormOptions() = GiftFormOptionsResult(
sizes = sizePriceRepository.findAll().filter { it.isActive }.map {
GiftSizePriceResult(it.sizeCode, it.sizeCode.displayName, it.basePriceCan, it.salePriceCan, it.isActive)
},
categories = categoryRepository.findAll().filter { it.isActive }.map {
GiftCategoryResult(it.id!!, it.categoryCode, it.name, it.requiresDamageWaiver, it.isActive)
}
)
@Transactional(readOnly = true)
fun getGifts(memberId: Long, type: GiftListType, page: Int, size: Int): GiftListResult {
val normalizedPage = page.coerceAtLeast(0)
val normalizedSize = if (size <= 0) DEFAULT_PAGE_SIZE else size
val pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt", "id"))
val gifts = when (type) {
GiftListType.SENT -> giftRepository.findBySenderMemberId(memberId, pageable)
GiftListType.RECEIVED -> giftRepository.findByRecipientMemberIdAndStatusIn(
memberId,
RECIPIENT_VISIBLE_STATUSES,
pageable
)
GiftListType.ALL -> giftRepository.findBySenderMemberIdOrRecipientMemberIdAndStatusIn(
memberId,
memberId,
RECIPIENT_VISIBLE_STATUSES,
pageable
)
}
return GiftListResult(
totalCount = gifts.totalElements,
items = gifts.content.map { it.toGiftListItem(memberId) },
page = normalizedPage,
size = normalizedSize,
hasNext = gifts.hasNext()
)
}
@Transactional(readOnly = true)
fun getGiftDetail(memberId: Long, applicationNo: String): GiftDetailResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val direction = gift.directionFor(memberId)
val sender = memberRepository.findByIdOrNull(gift.senderMemberId) ?: throw invalidRequest()
val recipient = memberRepository.findByIdOrNull(gift.recipientMemberId) ?: throw invalidRequest()
val recipientAddress = if (direction == GiftDirection.RECEIVED) delivery.toRecipientAddressResult() else null
return GiftDetailResult(
applicationNo = gift.applicationNo,
direction = direction,
status = gift.status,
statusName = gift.status.displayName,
giftInfo = GiftDetailInfoResult(
recipientCreatorNickname = recipient.nickname.takeIf { direction == GiftDirection.SENT },
senderNickname = sender.nickname.takeIf { direction == GiftDirection.RECEIVED },
sizeName = gift.sizeCode.displayName,
categoryName = gift.categoryNameSnapshot,
applicationNo = gift.applicationNo.takeIf { direction == GiftDirection.SENT },
paidCan = gift.salePriceCan.takeIf { direction == GiftDirection.SENT },
tracking = delivery.tracking().takeIf { direction == GiftDirection.SENT },
shippingRequestedAt = gift.createdAt.takeIf { direction == GiftDirection.RECEIVED }
),
senderInfo = delivery.toSenderInfo().takeIf { direction == GiftDirection.SENT },
recipientAddress = recipientAddress,
mailbox = null,
trackingRequired = direction == GiftDirection.SENT && gift.status == GiftStatus.RECEIVED,
recipientAddressRequired = direction == GiftDirection.RECEIVED && recipientAddress == null &&
gift.status !in TERMINAL_STATUSES,
recipientAddressDeadlineAt = delivery.recipientAddressDeadlineAt,
delivery = GiftDeliveryInfoResult(
canceledAt = delivery.canceledAt,
undeliverableAt = delivery.undeliverableAt,
undeliverableReason = delivery.undeliverableReason
),
statusTimeline = gift.toStatusTimeline(delivery)
)
}
private fun Gift.toGiftListItem(memberId: Long) = GiftListItemResult(
applicationNo = applicationNo,
direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED,
status = status,
statusName = status.displayName,
priceCan = salePriceCan,
categoryName = categoryNameSnapshot,
sizeName = sizeCode.displayName,
createdAt = createdAt
)
private fun Gift.directionFor(memberId: Long) = when {
senderMemberId == memberId -> GiftDirection.SENT
recipientMemberId == memberId && status in RECIPIENT_VISIBLE_STATUSES -> GiftDirection.RECEIVED
else -> throw invalidRequest()
}
private fun GiftDelivery.toSenderInfo() = GiftAddressResult(
name = senderName,
phoneNumber = senderPhoneNumber,
address = formatAddress(senderZipCode, senderAddress, senderAddressDetail)
)
private fun GiftDelivery.toRecipientAddressResult(): GiftAddressResult? {
val name = recipientName ?: return null
val phoneNumber = recipientPhoneNumber ?: return null
val zipCode = recipientZipCode ?: return null
val address = recipientAddress ?: return null
return GiftAddressResult(name, phoneNumber, formatAddress(zipCode, address, recipientAddressDetail))
}
private fun GiftDelivery.tracking() = if (courierCompanyName != null && trackingNumber != null) {
"${courierCompanyName}_$trackingNumber"
} else {
null
}
private fun Gift.toStatusTimeline(delivery: GiftDelivery) = NORMAL_PROGRESS_STATUSES.map { timelineStatus ->
GiftStatusTimelineResult(
status = timelineStatus,
statusName = timelineStatus.displayName,
occurredAt = when (timelineStatus) {
GiftStatus.RECEIVED -> createdAt
GiftStatus.TRACKING_REGISTERED -> delivery.trackingRegisteredAt
GiftStatus.ARRIVED_AT_MAILBOX -> delivery.arrivedAtMailboxAt
GiftStatus.INSPECTION_COMPLETED -> delivery.inspectionCompletedAt
GiftStatus.DELIVERED -> delivery.deliveredAt
GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> null
}
)
}
private fun formatAddress(zipCode: String, address: String, detail: String?) =
"($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail"
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
private companion object {
const val DEFAULT_PAGE_SIZE = 20
val RECIPIENT_VISIBLE_STATUSES = setOf(
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED,
GiftStatus.DELIVERED,
GiftStatus.UNDELIVERABLE
)
val NORMAL_PROGRESS_STATUSES = listOf(
GiftStatus.RECEIVED,
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED,
GiftStatus.DELIVERED
)
val TERMINAL_STATUSES = setOf(GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED)
}
}
@@ -0,0 +1,408 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
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.domain.GiftDirection
import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
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.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:gift-query;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class GiftQueryServiceTest @Autowired constructor(
private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository,
private val memberRepository: MemberRepository
) {
@Test
fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() {
val activeCategory = categoryRepository.save(GiftCategory("DOLL", "인형", true, true))
categoryRepository.save(GiftCategory("FOOD", "간식", false, false))
sizePriceRepository.save(GiftSizePrice(GiftSize.SMALL, 100, 80, true))
sizePriceRepository.save(GiftSizePrice(GiftSize.MEDIUM, 150, 120, false))
val result = queryService().getFormOptions()
assertEquals(listOf(GiftSize.SMALL), result.sizes.map { it.sizeCode })
assertEquals(listOf(100), result.sizes.map { it.basePriceCan })
assertEquals(listOf(80), result.sizes.map { it.salePriceCan })
assertEquals(listOf(activeCategory.id), result.categories.map { it.categoryId })
assertEquals("인형", result.categories.single().name)
assertEquals(true, result.categories.single().requiresDamageWaiver)
}
@Test
@DisplayName("보낸 선물은 접수 직후 선물함에 노출된다")
fun shouldListReceivedGiftForSender() {
saveGift("G-SENT-RECEIVED", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED)
val result = queryService().getGifts(
memberId = MEMBER_ID,
type = GiftListType.SENT,
page = 0,
size = 20
)
assertEquals(1L, result.totalCount)
assertEquals(1, result.items.size)
assertEquals("G-SENT-RECEIVED", result.items.single().applicationNo)
assertEquals(GiftDirection.SENT, result.items.single().direction)
assertEquals(GiftStatus.RECEIVED, result.items.single().status)
assertEquals(GiftStatus.RECEIVED.displayName, result.items.single().statusName)
assertEquals(80, result.items.single().priceCan)
assertEquals("인형", result.items.single().categoryName)
assertEquals("소형", result.items.single().sizeName)
assertNotNull(result.items.single().createdAt)
}
@Test
@DisplayName("받은 선물은 운송장 등록 이후 상태만 선물함에 노출된다")
fun shouldExcludeReceivedOnlyGiftAndListVisibleGiftForRecipient() {
saveGift("G-RECEIVED-HIDDEN", senderMemberId = 2L, recipientMemberId = MEMBER_ID, status = GiftStatus.RECEIVED)
saveGift(
"G-RECEIVED-VISIBLE",
senderMemberId = 3L,
recipientMemberId = MEMBER_ID,
status = GiftStatus.TRACKING_REGISTERED
)
val result = queryService().getGifts(
memberId = MEMBER_ID,
type = GiftListType.RECEIVED,
page = 0,
size = 20
)
assertEquals(1L, result.totalCount)
assertEquals(listOf("G-RECEIVED-VISIBLE"), result.items.map { it.applicationNo })
assertEquals(GiftDirection.RECEIVED, result.items.single().direction)
}
@Test
@DisplayName("전체 선물함은 보낸 선물과 노출 가능한 받은 선물을 방향과 함께 반환한다")
fun shouldListSentAndVisibleReceivedGiftsWithDirectionsForAll() {
saveGift("G-ALL-SENT", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.CANCELED)
saveGift(
"G-ALL-RECEIVED",
senderMemberId = 3L,
recipientMemberId = MEMBER_ID,
status = GiftStatus.ARRIVED_AT_MAILBOX
)
saveGift("G-ALL-HIDDEN", senderMemberId = 4L, recipientMemberId = MEMBER_ID, status = GiftStatus.RECEIVED)
val result = queryService().getGifts(
memberId = MEMBER_ID,
type = GiftListType.ALL,
page = 0,
size = 20
)
assertEquals(2L, result.totalCount)
assertEquals(
mapOf("G-ALL-SENT" to GiftDirection.SENT, "G-ALL-RECEIVED" to GiftDirection.RECEIVED),
result.items.associate { it.applicationNo to it.direction }
)
}
@Test
@DisplayName("선물함 page와 size는 음수 또는 0이면 기본값으로 보정된다")
fun shouldNormalizeGiftListPageAndSize() {
saveGift("G-PAGING", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED)
val result = queryService().getGifts(
memberId = MEMBER_ID,
type = GiftListType.SENT,
page = -1,
size = 0
)
assertEquals(0, result.page)
assertEquals(20, result.size)
assertEquals(false, result.hasNext)
assertEquals(listOf("G-PAGING"), result.items.map { it.applicationNo })
}
@Test
@DisplayName("발신자는 자신의 선물 상세와 발신자 정보만 조회한다")
fun shouldReturnSentGiftDetailWithoutRecipientAddress() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val gift = saveGift(
applicationNo = "G-SENT-DETAIL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
val delivery = saveDelivery(gift)
delivery.recipientName = "수신자"
delivery.recipientPhoneNumber = "01098765432"
delivery.recipientZipCode = "04524"
delivery.recipientAddress = "서울시 중구"
delivery.recipientAddressDetail = "202호"
val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo)
assertEquals(GiftDirection.SENT, result.direction)
assertEquals(recipient.nickname, result.giftInfo.recipientCreatorNickname)
assertNull(result.giftInfo.senderNickname)
assertEquals("소형", result.giftInfo.sizeName)
assertEquals("인형", result.giftInfo.categoryName)
assertEquals(gift.applicationNo, result.giftInfo.applicationNo)
assertEquals(80, result.giftInfo.paidCan)
assertNull(result.giftInfo.tracking)
assertNull(result.giftInfo.shippingRequestedAt)
assertEquals("발신자", result.senderInfo?.name)
assertEquals("01012345678", result.senderInfo?.phoneNumber)
assertEquals("(06234) 서울시 강남구, 101동 1001호", result.senderInfo?.address)
assertNull(result.recipientAddress)
assertNull(result.mailbox)
assertEquals(true, result.trackingRequired)
assertEquals(false, result.recipientAddressRequired)
assertEquals(
listOf(
GiftStatus.RECEIVED,
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED,
GiftStatus.DELIVERED
),
result.statusTimeline.map { it.status }
)
assertNotNull(result.statusTimeline[0].occurredAt)
assertNull(result.statusTimeline[1].occurredAt)
}
@Test
@DisplayName("수신자는 노출된 선물 상세에서 자신의 배송지와 발신자 닉네임만 조회한다")
fun shouldReturnReceivedGiftDetailWithoutSenderInformation() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val gift = saveGift(
applicationNo = "G-RECEIVED-DETAIL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
saveDelivery(gift).apply {
trackingRegisteredAt = NOW.plusHours(1)
recipientName = "수신자"
recipientPhoneNumber = "01098765432"
recipientZipCode = "04524"
recipientAddress = "서울시 중구"
recipientAddressDetail = "202호"
}
val result = queryService().getGiftDetail(recipient.id!!, gift.applicationNo)
assertEquals(GiftDirection.RECEIVED, result.direction)
assertNull(result.giftInfo.recipientCreatorNickname)
assertEquals(sender.nickname, result.giftInfo.senderNickname)
assertEquals("소형", result.giftInfo.sizeName)
assertEquals("인형", result.giftInfo.categoryName)
assertNull(result.giftInfo.applicationNo)
assertNull(result.giftInfo.paidCan)
assertNull(result.giftInfo.tracking)
assertNotNull(result.giftInfo.shippingRequestedAt)
assertNull(result.senderInfo)
assertEquals("수신자", result.recipientAddress?.name)
assertEquals("01098765432", result.recipientAddress?.phoneNumber)
assertEquals("(04524) 서울시 중구, 202호", result.recipientAddress?.address)
assertNull(result.mailbox)
assertEquals(false, result.trackingRequired)
assertEquals(false, result.recipientAddressRequired)
assertNotNull(result.statusTimeline[0].occurredAt)
assertEquals(NOW.plusHours(1), result.statusTimeline[1].occurredAt)
assertNull(result.statusTimeline[2].occurredAt)
}
@Test
@DisplayName("상세 조회는 수신자 노출 조건과 관계없는 회원 및 배송 정보 누락을 거부한다")
fun shouldRejectHiddenOrUnrelatedGiftDetailAndMissingDelivery() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val other = saveMember("other")
val hiddenGift = saveGift(
applicationNo = "G-HIDDEN-DETAIL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
saveDelivery(hiddenGift)
val visibleGift = saveGift(
applicationNo = "G-VISIBLE-DETAIL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
saveDelivery(visibleGift)
val missingDeliveryGift = saveGift(
applicationNo = "G-MISSING-DELIVERY",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
val service = queryService()
val hiddenException = assertThrows(SodaException::class.java) {
service.getGiftDetail(recipient.id!!, hiddenGift.applicationNo)
}
val unrelatedException = assertThrows(SodaException::class.java) {
service.getGiftDetail(other.id!!, visibleGift.applicationNo)
}
val missingDeliveryException = assertThrows(SodaException::class.java) {
service.getGiftDetail(sender.id!!, missingDeliveryGift.applicationNo)
}
assertEquals("common.error.invalid_request", hiddenException.messageKey)
assertEquals("common.error.invalid_request", unrelatedException.messageKey)
assertEquals("common.error.invalid_request", missingDeliveryException.messageKey)
}
@Test
@DisplayName("수신자 배송지 입력 필요 여부는 미입력인 진행 상태에서만 참이다")
fun shouldRequireRecipientAddressOnlyForNonTerminalReceivedGift() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val pendingGift = saveGift(
applicationNo = "G-ADDRESS-PENDING",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
saveDelivery(pendingGift).trackingRegisteredAt = NOW
val terminalGift = saveGift(
applicationNo = "G-ADDRESS-TERMINAL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.UNDELIVERABLE
)
saveDelivery(terminalGift).apply {
trackingRegisteredAt = NOW
undeliverableAt = NOW.plusHours(1)
undeliverableReason = "배송지 미입력 기한 초과"
}
val service = queryService()
val pendingResult = service.getGiftDetail(recipient.id!!, pendingGift.applicationNo)
val terminalResult = service.getGiftDetail(recipient.id!!, terminalGift.applicationNo)
assertEquals(true, pendingResult.recipientAddressRequired)
assertNull(pendingResult.recipientAddress)
assertEquals(false, terminalResult.recipientAddressRequired)
}
@Test
@DisplayName("취소와 전달 불가 상세는 정상 진행 타임라인과 종료 정보를 함께 반환한다")
fun shouldReturnTerminalInformationWithNormalProgressTimeline() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val canceledGift = saveGift(
applicationNo = "G-CANCELED-DETAIL",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.CANCELED
)
saveDelivery(canceledGift).canceledAt = NOW.plusHours(1)
val undeliverableGift = saveGift(
applicationNo = "G-UNDELIVERABLE-X",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.UNDELIVERABLE
)
saveDelivery(undeliverableGift).apply {
trackingRegisteredAt = NOW.plusHours(1)
arrivedAtMailboxAt = NOW.plusHours(2)
undeliverableAt = NOW.plusHours(3)
undeliverableReason = "검수 실패"
}
val service = queryService()
val canceledResult = service.getGiftDetail(sender.id!!, canceledGift.applicationNo)
val undeliverableResult = service.getGiftDetail(sender.id!!, undeliverableGift.applicationNo)
assertEquals(NOW.plusHours(1), canceledResult.delivery.canceledAt)
assertNull(canceledResult.statusTimeline[1].occurredAt)
assertEquals(NOW.plusHours(1), undeliverableResult.statusTimeline[1].occurredAt)
assertEquals(NOW.plusHours(2), undeliverableResult.statusTimeline[2].occurredAt)
assertNull(undeliverableResult.statusTimeline[3].occurredAt)
assertEquals(NOW.plusHours(3), undeliverableResult.delivery.undeliverableAt)
assertEquals("검수 실패", undeliverableResult.delivery.undeliverableReason)
}
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 = 1L,
categoryNameSnapshot = "인형",
salePriceCan = 80,
canUsageId = 1L,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW,
senderPrivacyAgreedAt = NOW
)
)
private fun saveMember(nickname: String) = memberRepository.save(Member(password = "password", nickname = nickname))
private fun saveDelivery(gift: Gift) = deliveryRepository.save(
GiftDelivery(
giftId = gift.id!!,
senderName = "발신자",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
senderAddressDetail = "101동 1001호",
trackingDeadlineAt = NOW.plusDays(3)
)
)
private fun queryService() = GiftQueryService(
categoryRepository,
sizePriceRepository,
giftRepository,
deliveryRepository,
memberRepository
)
private companion object {
const val MEMBER_ID = 1L
val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
}
}