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,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)
}
}