diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt new file mode 100644 index 00000000..503c4c37 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt @@ -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, + val categories: List +) + +data class GiftListResult( + val totalCount: Long, + val items: List, + 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 +) + +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) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt new file mode 100644 index 00000000..ce55aae2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt @@ -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) + } +}