diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt new file mode 100644 index 00000000..348539ce --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt @@ -0,0 +1,368 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.common.SodaException +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.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.GiftReview +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReviewRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +data class GiftApplicationCommand( + val recipientMemberId: Long, + val senderName: String, + val senderPhoneNumber: String, + val senderZipCode: String, + val senderAddress: String, + val senderAddressDetail: String?, + val sizeCode: GiftSize, + val categoryId: Long, + val senderTermsAgreed: Boolean, + val senderPrivacyAgreed: Boolean, + val damageWaiverAgreed: Boolean +) + +data class GiftApplicationResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val priceCan: Int, + val trackingDeadlineAt: LocalDateTime +) + +data class GiftCancellationResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val priceCan: Int, + val canceledAt: LocalDateTime +) + +data class GiftTrackingRegistrationCommand( + val courierCompanyName: String, + val trackingNumber: String +) + +data class GiftTrackingRegistrationResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val courierCompanyName: String, + val trackingNumber: String, + val trackingRegisteredAt: LocalDateTime, + val recipientAddressDeadlineAt: LocalDateTime +) + +data class GiftRecipientAddressRegistrationCommand( + val name: String, + val phoneNumber: String, + val zipCode: String, + val address: String, + val addressDetail: String?, + val recipientTermsAgreed: Boolean, + val recipientPrivacyAgreed: Boolean +) + +data class GiftRecipientAddressRegistrationResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val recipientAddressRegisteredAt: LocalDateTime +) + +data class GiftDeliveryConfirmationResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val deliveredAt: LocalDateTime +) + +data class GiftReviewCommand( + val rating: Int, + val keywords: List, + val comment: String? +) + +data class GiftReviewResult( + val reviewId: Long, + val applicationNo: String, + val rating: Int, + val keywords: List, + val comment: String?, + val createdAt: LocalDateTime? +) + +@Service +class GiftCommandService( + private val giftRepository: GiftRepository, + private val deliveryRepository: GiftDeliveryRepository, + private val reviewRepository: GiftReviewRepository, + private val categoryRepository: GiftCategoryRepository, + private val sizePriceRepository: GiftSizePriceRepository, + private val memberRepository: MemberRepository, + private val applicationNoGenerator: GiftApplicationNoGenerator, + private val canPaymentService: CanPaymentService, + private val giftPushService: GiftPushService, + private val now: () -> LocalDateTime = { LocalDateTime.now() } +) { + @Transactional + fun registerGift(member: Member, command: GiftApplicationCommand): GiftApplicationResult { + validate(command) + val senderMemberId = member.id ?: throw invalidRequest() + memberRepository.findCreatorByIdOrNull(command.recipientMemberId) ?: throw invalidRequest() + val category = categoryRepository.findByIdOrNull(command.categoryId)?.takeIf { it.isActive } ?: throw invalidRequest() + if (category.requiresDamageWaiver && !command.damageWaiverAgreed) throw invalidRequest() + val sizePrice = sizePriceRepository.findBySizeCode(command.sizeCode)?.takeIf { it.isActive } ?: throw invalidRequest() + val appliedAt = now() + val trackingDeadlineAt = appliedAt.plusDays(3) + val applicationNo = applicationNoGenerator.generate(appliedAt) + val canUsageId = canPaymentService.spendGiftCan( + senderMemberId, + sizePrice.salePriceCan, + member.container.ifBlank { "web" } + ) + val gift = giftRepository.save( + Gift( + applicationNo = applicationNo, + senderMemberId = senderMemberId, + recipientMemberId = command.recipientMemberId, + status = GiftStatus.RECEIVED, + sizeCode = command.sizeCode, + categoryId = category.id ?: throw invalidRequest(), + categoryNameSnapshot = category.name, + salePriceCan = sizePrice.salePriceCan, + canUsageId = canUsageId, + senderTermsAgreed = command.senderTermsAgreed, + senderPrivacyAgreed = command.senderPrivacyAgreed, + senderTermsAgreedAt = appliedAt, + senderPrivacyAgreedAt = appliedAt, + damageWaiverAgreed = command.damageWaiverAgreed + ) + ) + deliveryRepository.save( + GiftDelivery( + giftId = gift.id ?: throw invalidRequest(), + senderName = command.senderName, + senderPhoneNumber = command.senderPhoneNumber, + senderZipCode = command.senderZipCode, + senderAddress = command.senderAddress, + senderAddressDetail = command.senderAddressDetail, + trackingDeadlineAt = trackingDeadlineAt + ) + ) + return GiftApplicationResult( + applicationNo = applicationNo, + status = GiftStatus.RECEIVED, + statusName = GiftStatus.RECEIVED.displayName, + priceCan = sizePrice.salePriceCan, + trackingDeadlineAt = trackingDeadlineAt + ) + } + + @Transactional + fun writeReview(member: Member, applicationNo: String, command: GiftReviewCommand): GiftReviewResult { + val senderMemberId = member.id ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val giftId = gift.id ?: throw invalidRequest() + if ( + gift.senderMemberId != senderMemberId || + gift.status != GiftStatus.DELIVERED || + reviewRepository.findByGiftId(giftId) != null + ) { + throw invalidRequest() + } + validate(command) + + val review = reviewRepository.save( + GiftReview( + giftId = giftId, + senderMemberId = senderMemberId, + rating = command.rating, + keywords = command.keywords.joinToString(","), + comment = command.comment + ) + ) + + return GiftReviewResult( + reviewId = review.id ?: throw invalidRequest(), + applicationNo = gift.applicationNo, + rating = review.rating, + keywords = review.keywords?.takeIf { it.isNotBlank() }?.split(",") ?: emptyList(), + comment = review.comment, + createdAt = review.createdAt + ) + } + + @Transactional + fun cancelGift(member: Member, applicationNo: String): GiftCancellationResult { + val senderMemberId = member.id ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest() + + val canceledAt = now() + gift.status = GiftStatus.CANCELED + deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest())?.canceledAt = canceledAt + canPaymentService.refundGiftCan(senderMemberId, gift.canUsageId) + + return GiftCancellationResult( + applicationNo = gift.applicationNo, + status = gift.status, + statusName = gift.status.displayName, + priceCan = gift.salePriceCan, + canceledAt = canceledAt + ) + } + + @Transactional + fun registerTracking( + member: Member, + applicationNo: String, + command: GiftTrackingRegistrationCommand + ): GiftTrackingRegistrationResult { + val senderMemberId = member.id ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest() + validate(command) + val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val trackingRegisteredAt = now() + val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7) + + gift.status = GiftStatus.TRACKING_REGISTERED + delivery.courierCompanyName = command.courierCompanyName + delivery.trackingNumber = command.trackingNumber + delivery.trackingRegisteredAt = trackingRegisteredAt + delivery.recipientAddressDeadlineAt = recipientAddressDeadlineAt + giftPushService.sendTrackingRegisteredToRecipient(gift.applicationNo, gift.recipientMemberId) + + return GiftTrackingRegistrationResult( + applicationNo = gift.applicationNo, + status = gift.status, + statusName = gift.status.displayName, + courierCompanyName = command.courierCompanyName, + trackingNumber = command.trackingNumber, + trackingRegisteredAt = trackingRegisteredAt, + recipientAddressDeadlineAt = recipientAddressDeadlineAt + ) + } + + @Transactional + fun registerRecipientAddress( + member: Member, + applicationNo: String, + command: GiftRecipientAddressRegistrationCommand + ): GiftRecipientAddressRegistrationResult { + val recipientMemberId = member.id ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.recipientMemberId != recipientMemberId || gift.status !in RECIPIENT_ADDRESS_REGISTRABLE_STATUSES) { + throw invalidRequest() + } + validate(command) + val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val registeredAt = now() + + delivery.recipientName = command.name + delivery.recipientPhoneNumber = command.phoneNumber + delivery.recipientZipCode = command.zipCode + delivery.recipientAddress = command.address + delivery.recipientAddressDetail = command.addressDetail + gift.recipientTermsAgreed = command.recipientTermsAgreed + gift.recipientPrivacyAgreed = command.recipientPrivacyAgreed + gift.recipientTermsAgreedAt = registeredAt + gift.recipientPrivacyAgreedAt = registeredAt + + return GiftRecipientAddressRegistrationResult( + applicationNo = gift.applicationNo, + status = gift.status, + statusName = gift.status.displayName, + recipientAddressRegisteredAt = registeredAt + ) + } + + @Transactional + fun confirmDelivery(member: Member, applicationNo: String): GiftDeliveryConfirmationResult { + val recipientMemberId = member.id ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.recipientMemberId != recipientMemberId || gift.status != GiftStatus.INSPECTION_COMPLETED) { + throw invalidRequest() + } + val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val deliveredAt = now() + + gift.status = GiftStatus.DELIVERED + delivery.deliveredAt = deliveredAt + + return GiftDeliveryConfirmationResult( + applicationNo = gift.applicationNo, + status = gift.status, + statusName = gift.status.displayName, + deliveredAt = deliveredAt + ) + } + + private fun validate(command: GiftApplicationCommand) { + if ( + command.senderName.isBlank() || + command.senderPhoneNumber.isBlank() || + command.senderZipCode.isBlank() || + command.senderAddress.isBlank() || + !command.senderTermsAgreed || + !command.senderPrivacyAgreed + ) { + throw invalidRequest() + } + } + + private fun validate(command: GiftTrackingRegistrationCommand) { + if ( + command.courierCompanyName.isBlank() || + command.courierCompanyName.length > 50 || + command.trackingNumber.isBlank() + ) { + throw invalidRequest() + } + } + + private fun validate(command: GiftRecipientAddressRegistrationCommand) { + if ( + command.name.isBlank() || + command.phoneNumber.isBlank() || + command.zipCode.isBlank() || + command.address.isBlank() || + !command.recipientTermsAgreed || + !command.recipientPrivacyAgreed + ) { + throw invalidRequest() + } + } + + private fun validate(command: GiftReviewCommand) { + if ( + command.rating !in 1..5 || + command.keywords.any { it.length > 255 } || + (command.comment?.length ?: 0) > 255 + ) { + throw invalidRequest() + } + } + + private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request") + + private companion object { + val RECIPIENT_ADDRESS_REGISTRABLE_STATUSES = setOf( + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt new file mode 100644 index 00000000..be038d71 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt @@ -0,0 +1,868 @@ +package kr.co.vividnext.sodalive.v2.gift + +import kr.co.vividnext.sodalive.can.CanRepository +import kr.co.vividnext.sodalive.can.charge.Charge +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.payment.Payment +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +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.GiftReview +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReviewRepository +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.GiftApplicationCommand +import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationNoGenerator +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.GiftPushService +import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand +import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewCommand +import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand +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.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mockito +import java.time.LocalDateTime +import java.util.Optional + +class GiftCommandServiceTest { + @Test + @DisplayName("선물 캔 사용은 한 번만 환불할 수 있다") + fun shouldSpendAndRefundGiftCanOnlyOnce() { + // Given: 충전 캔을 보유한 선물 발신자와 결제 서비스를 준비한다. + val member = Member(password = "password", nickname = "sender") + member.id = MEMBER_ID + member.pgChargeCan = INITIAL_CAN + val charge = Charge(chargeCan = INITIAL_CAN, rewardCan = 0) + charge.id = CHARGE_ID + charge.member = member + charge.payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG) + val fixture = CanPaymentFixture(member, charge) + + // When: 선물용 캔을 사용한 뒤 해당 사용 내역을 환불한다. + val useCanId = fixture.service.spendGiftCan( + memberId = MEMBER_ID, + needCan = GIFT_PRICE_CAN, + container = "web" + ) + val afterSpendCan = member.getChargeCan("web") + member.getRewardCan("web") + fixture.service.refundGiftCan(memberId = MEMBER_ID, useCanId = useCanId) + val afterRefundCan = member.getChargeCan("web") + member.getRewardCan("web") + + // Then: 발신자와 GIFT 용도가 저장되고, 잔액이 복원되며 재환불은 거부된다. + assertEquals(CanUsage.GIFT, fixture.savedUseCan.canUsage) + assertEquals(member, fixture.savedUseCan.member) + assertEquals(INITIAL_CAN - GIFT_PRICE_CAN, afterSpendCan) + assertEquals(INITIAL_CAN, afterRefundCan) + assertEquals(true, fixture.savedUseCan.isRefund) + assertThrows(SodaException::class.java) { + fixture.service.refundGiftCan(memberId = MEMBER_ID, useCanId = useCanId) + } + } + + @Test + @DisplayName("선물 신청은 가격과 약관 동의, 배송 기한을 스냅샷으로 저장하고 캔을 차감한다") + fun shouldRegisterGiftWithPriceAgreementAndDeliverySnapshots() { + // Given: 활성 선물 카테고리와 가격, 발신자 및 수신 크리에이터를 준비한다. + val fixture = GiftApplicationFixture() + + // When: 팬이 선물을 신청한다. + val result = fixture.service.registerGift(fixture.sender, validGiftApplicationCommand()) + + // Then: 신청 정보와 배송 기한을 저장하고 판매가만큼 캔을 차감한다. + assertEquals(APPLICATION_NO, result.applicationNo) + assertEquals(GiftStatus.RECEIVED, result.status) + assertEquals(GiftStatus.RECEIVED.displayName, result.statusName) + assertEquals(SALE_PRICE_CAN, result.priceCan) + assertEquals(NOW.plusDays(3), result.trackingDeadlineAt) + assertEquals(SENDER_ID, fixture.savedGift.senderMemberId) + assertEquals(RECIPIENT_ID, fixture.savedGift.recipientMemberId) + assertEquals(GiftStatus.RECEIVED, fixture.savedGift.status) + assertEquals(GiftSize.SMALL, fixture.savedGift.sizeCode) + assertEquals(CATEGORY_ID, fixture.savedGift.categoryId) + assertEquals("인형", fixture.savedGift.categoryNameSnapshot) + assertEquals(SALE_PRICE_CAN, fixture.savedGift.salePriceCan) + assertEquals(USE_CAN_ID, fixture.savedGift.canUsageId) + assertEquals(true, fixture.savedGift.senderTermsAgreed) + assertEquals(true, fixture.savedGift.senderPrivacyAgreed) + assertEquals(NOW, fixture.savedGift.senderTermsAgreedAt) + assertEquals(NOW, fixture.savedGift.senderPrivacyAgreedAt) + assertEquals(true, fixture.savedGift.damageWaiverAgreed) + assertEquals(GIFT_ID, fixture.savedDelivery.giftId) + assertEquals("홍길동", fixture.savedDelivery.senderName) + assertEquals("01012345678", fixture.savedDelivery.senderPhoneNumber) + assertEquals("06234", fixture.savedDelivery.senderZipCode) + assertEquals("서울시 강남구", fixture.savedDelivery.senderAddress) + assertEquals("101동 1001호", fixture.savedDelivery.senderAddressDetail) + assertEquals(NOW.plusDays(3), fixture.savedDelivery.trackingDeadlineAt) + Mockito.verify(fixture.canPaymentService).spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos") + } + + @Test + @DisplayName("보낸 팬은 운송장 등록 전 선물을 취소하고 전액 환불받는다") + fun shouldCancelReceivedGiftAndRefundOnce() { + // Given: 접수 상태의 선물과 발신자 배송 정보를 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForCancellation() + + // When: 보낸 팬이 선물을 취소한다. + val result = fixture.service.cancelGift(fixture.sender, APPLICATION_NO) + + // Then: 선물과 배송 정보에 취소 시각을 저장하고 한 번만 환불한다. + assertEquals( + GiftCancellationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.CANCELED, + statusName = GiftStatus.CANCELED.displayName, + priceCan = SALE_PRICE_CAN, + canceledAt = NOW + ), + result + ) + assertEquals(GiftStatus.CANCELED, fixture.cancellationGift.status) + assertEquals(NOW, fixture.cancellationDelivery.canceledAt) + Mockito.verify(fixture.canPaymentService).refundGiftCan(SENDER_ID, USE_CAN_ID) + } + + @Test + @DisplayName("보낸 팬은 접수된 선물의 운송장을 등록하고 크리에이터 배송지 입력 기한을 생성한다") + fun shouldRegisterTrackingForReceivedGift() { + // Given: 접수 상태의 선물과 발신자 배송 정보를 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForTracking() + + // When: 보낸 팬이 택배사와 운송장 번호를 등록한다. + val result = fixture.service.registerTracking( + fixture.sender, + APPLICATION_NO, + validGiftTrackingRegistrationCommand() + ) + + // Then: 발송 확인 상태와 운송장 정보, 수신자 주소 입력 기한을 저장한다. + assertEquals( + GiftTrackingRegistrationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.TRACKING_REGISTERED, + statusName = GiftStatus.TRACKING_REGISTERED.displayName, + courierCompanyName = "CJ대한통운", + trackingNumber = "1234567890", + trackingRegisteredAt = NOW, + recipientAddressDeadlineAt = NOW.plusDays(7) + ), + result + ) + assertEquals(GiftStatus.TRACKING_REGISTERED, fixture.trackingGift.status) + assertEquals("CJ대한통운", fixture.trackingDelivery.courierCompanyName) + assertEquals("1234567890", fixture.trackingDelivery.trackingNumber) + assertEquals(NOW, fixture.trackingDelivery.trackingRegisteredAt) + assertEquals(NOW.plusDays(7), fixture.trackingDelivery.recipientAddressDeadlineAt) + Mockito.verify(fixture.giftPushService).sendTrackingRegisteredToRecipient(APPLICATION_NO, RECIPIENT_ID) + } + + @ParameterizedTest + @MethodSource("recipientAddressRegistrableStatuses") + @DisplayName("받는 크리에이터는 운송장 등록 이후 종료 전 상태에서 주소와 수령 동의를 등록한다") + fun shouldRegisterRecipientAddressAndAgreements(status: GiftStatus) { + // Given: 주소 입력이 허용된 선물과 받는 크리에이터를 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForRecipientAddress(status = status) + + // When: 받는 크리에이터가 배송지와 수령 약관 동의를 등록한다. + val result = fixture.service.registerRecipientAddress( + fixture.creator, + APPLICATION_NO, + validGiftRecipientAddressRegistrationCommand() + ) + + // Then: 주소 스냅샷과 두 동의 시각을 저장하고 현재 상태를 반환한다. + assertEquals( + GiftRecipientAddressRegistrationResult( + applicationNo = APPLICATION_NO, + status = status, + statusName = status.displayName, + recipientAddressRegisteredAt = NOW + ), + result + ) + assertEquals("김소다", fixture.recipientAddressDelivery.recipientName) + assertEquals("01098765432", fixture.recipientAddressDelivery.recipientPhoneNumber) + assertEquals("04524", fixture.recipientAddressDelivery.recipientZipCode) + assertEquals("서울시 중구", fixture.recipientAddressDelivery.recipientAddress) + assertEquals("202호", fixture.recipientAddressDelivery.recipientAddressDetail) + assertEquals(true, fixture.recipientAddressGift.recipientTermsAgreed) + assertEquals(true, fixture.recipientAddressGift.recipientPrivacyAgreed) + assertEquals(NOW, fixture.recipientAddressGift.recipientTermsAgreedAt) + assertEquals(NOW, fixture.recipientAddressGift.recipientPrivacyAgreedAt) + } + + @Test + @DisplayName("받는 크리에이터는 검수 완료 선물의 수령을 확인하고 전달 완료 시각을 저장한다") + fun shouldConfirmDeliveryForInspectionCompletedGift() { + val fixture = GiftApplicationFixture() + fixture.prepareGiftForDeliveryConfirmation() + + val result = fixture.service.confirmDelivery(fixture.creator, APPLICATION_NO) + + assertEquals( + GiftDeliveryConfirmationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.DELIVERED, + statusName = GiftStatus.DELIVERED.displayName, + deliveredAt = NOW + ), + result + ) + assertEquals(GiftStatus.DELIVERED, fixture.deliveryConfirmationGift.status) + assertEquals(NOW, fixture.deliveryConfirmationDelivery.deliveredAt) + Mockito.verifyNoInteractions(fixture.giftPushService) + } + + @Test + @DisplayName("보낸 팬은 전달 완료 선물에 리뷰를 한 번 작성한다") + fun shouldWriteReviewForDeliveredGiftBySender() { + val fixture = GiftApplicationFixture() + fixture.prepareGiftForReview() + + val result = fixture.service.writeReview(fixture.sender, APPLICATION_NO, validGiftReviewCommand()) + + assertEquals( + GiftReviewResult( + reviewId = REVIEW_ID, + applicationNo = APPLICATION_NO, + rating = 5, + keywords = listOf("배송이 빨라요", "안내가 친절해요"), + comment = "선물 전달 과정이 만족스러웠습니다.", + createdAt = NOW + ), + result + ) + assertEquals(GIFT_ID, fixture.savedReview.giftId) + assertEquals(SENDER_ID, fixture.savedReview.senderMemberId) + assertEquals(5, fixture.savedReview.rating) + assertEquals("배송이 빨라요,안내가 친절해요", fixture.savedReview.keywords) + assertEquals("선물 전달 과정이 만족스러웠습니다.", fixture.savedReview.comment) + } + + @ParameterizedTest + @MethodSource("invalidGiftReviewScenarios") + @DisplayName("리뷰 작성은 보낸 팬의 전달 완료 선물, 1회, 유효한 별점과 내용만 허용한다") + fun shouldRejectInvalidGiftReview(scenario: InvalidGiftReviewScenario) { + val fixture = GiftApplicationFixture() + fixture.prepareGiftForReview( + status = scenario.status, + senderMemberId = scenario.senderMemberId, + reviewExists = scenario.reviewExists + ) + val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId } + + val exception = assertThrows(SodaException::class.java) { + fixture.service.writeReview(requester, APPLICATION_NO, scenario.command) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verify(fixture.reviewRepository, Mockito.never()).save(Mockito.any(GiftReview::class.java)) + } + + @ParameterizedTest + @MethodSource("invalidGiftDeliveryConfirmationScenarios") + @DisplayName("수령 확인은 받는 크리에이터의 검수 완료 선물에만 가능하고 배송 정보가 필요하다") + fun shouldRejectInvalidGiftDeliveryConfirmation(scenario: InvalidGiftDeliveryConfirmationScenario) { + val fixture = GiftApplicationFixture() + fixture.prepareGiftForDeliveryConfirmation( + status = scenario.status, + recipientMemberId = scenario.recipientMemberId, + deliveryExists = scenario.deliveryExists + ) + val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId } + + val exception = assertThrows(SodaException::class.java) { + fixture.service.confirmDelivery(requester, APPLICATION_NO) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(scenario.status, fixture.deliveryConfirmationGift.status) + assertEquals(null, fixture.deliveryConfirmationDelivery.deliveredAt) + } + + @ParameterizedTest + @MethodSource("invalidGiftRecipientAddressRegistrationScenarios") + @DisplayName("수령자 주소 등록은 받는 크리에이터, 허용 상태, 필수 주소와 두 동의가 필요하다") + fun shouldRejectInvalidGiftRecipientAddressRegistration(scenario: InvalidGiftRecipientAddressRegistrationScenario) { + // Given: 권한, 상태 또는 입력값이 유효하지 않은 선물을 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForRecipientAddress( + status = scenario.status, + recipientMemberId = scenario.recipientMemberId, + deliveryExists = scenario.deliveryExists + ) + val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId } + + // When: 배송지 등록을 요청한다. + val exception = assertThrows(SodaException::class.java) { + fixture.service.registerRecipientAddress(requester, APPLICATION_NO, scenario.command) + } + + // Then: 공통 요청 오류를 반환하고 선물 상태와 수령 동의를 바꾸지 않는다. + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(scenario.status, fixture.recipientAddressGift.status) + assertEquals(false, fixture.recipientAddressGift.recipientTermsAgreed) + assertEquals(false, fixture.recipientAddressGift.recipientPrivacyAgreed) + } + + @ParameterizedTest + @MethodSource("invalidGiftTrackingRegistrationScenarios") + @DisplayName("운송장 등록은 보낸 팬의 접수 상태에서 유효한 택배사와 운송장 번호가 있어야 한다") + fun shouldRejectInvalidGiftTrackingRegistration(scenario: InvalidGiftTrackingRegistrationScenario) { + // Given: 운송장 등록 권한, 상태, 입력값 또는 배송 정보가 유효하지 않은 선물을 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForTracking(scenario.status, scenario.senderMemberId, scenario.deliveryExists) + val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId } + + // When: 운송장 등록을 요청한다. + val exception = assertThrows(SodaException::class.java) { + fixture.service.registerTracking(requester, APPLICATION_NO, scenario.command) + } + + // Then: 공통 요청 오류를 반환하고 선물 상태를 바꾸지 않는다. + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(scenario.status, fixture.trackingGift.status) + } + + @ParameterizedTest + @MethodSource("invalidGiftCancellationScenarios") + @DisplayName("선물 취소는 보낸 팬의 접수 상태에서만 가능하다") + fun shouldRejectGiftCancellationForInvalidSenderOrStatus(scenario: InvalidGiftCancellationScenario) { + // Given: 취소 권한 또는 상태가 유효하지 않은 선물을 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForCancellation(scenario.status, scenario.senderMemberId) + val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId } + + // When: 취소를 요청한다. + val exception = assertThrows(SodaException::class.java) { + fixture.service.cancelGift(requester, APPLICATION_NO) + } + + // Then: 환불하지 않고 공통 요청 오류를 반환한다. + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verify(fixture.canPaymentService, Mockito.never()).refundGiftCan( + Mockito.anyLong(), + Mockito.anyLong() + ) + } + + @ParameterizedTest + @MethodSource("invalidGiftApplicationScenarios") + @DisplayName("선물 신청은 필수 발신자 정보, 동의, 활성 옵션 및 수신 크리에이터를 검증한다") + fun shouldRejectInvalidGiftApplication(scenario: InvalidGiftApplicationScenario) { + // Given: 하나의 신청 조건만 유효하지 않은 선물 신청을 준비한다. + val fixture = GiftApplicationFixture() + fixture.category.isActive = scenario.categoryActive + fixture.category.requiresDamageWaiver = scenario.requiresDamageWaiver + fixture.sizePrice.isActive = scenario.sizePriceActive + fixture.setCreatorExists(scenario.creatorExists) + + // When: 유효하지 않은 신청을 등록한다. + val exception = assertThrows(SodaException::class.java) { + fixture.service.registerGift(fixture.sender, scenario.command) + } + + // Then: 캔을 차감하지 않고 공통 요청 오류를 반환한다. + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verifyNoInteractions(fixture.canPaymentService) + } + + private class CanPaymentFixture(member: Member, charge: Charge) { + private val canRepository = Mockito.mock(CanRepository::class.java) + private val memberRepository = Mockito.mock(MemberRepository::class.java) + private val chargeRepository = Mockito.mock(ChargeRepository::class.java) + private val useCanRepository = Mockito.mock(UseCanRepository::class.java) + private val useCanCalculateRepository = Mockito.mock(UseCanCalculateRepository::class.java) + private val useCanCalculates = mutableListOf() + lateinit var savedUseCan: UseCan + private set + + val service = CanPaymentService( + repository = canRepository, + memberRepository = memberRepository, + chargeRepository = chargeRepository, + useCanRepository = useCanRepository, + useCanCalculateRepository = useCanCalculateRepository, + messageSource = SodaMessageSource(), + langContext = LangContext(), + countryContext = CountryContext() + ) + + init { + Mockito.`when`(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)) + Mockito.`when`( + chargeRepository.getOldestChargeWhereRewardCanGreaterThan0(0L, MEMBER_ID, "web") + ).thenReturn(null) + Mockito.`when`( + chargeRepository.getOldestChargeWhereChargeCanGreaterThan0(0L, MEMBER_ID, "web") + ).thenReturn(charge) + Mockito.`when`(useCanRepository.save(Mockito.any(UseCan::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).apply { + id = USE_CAN_ID + savedUseCan = this + } + } + Mockito.`when`(useCanRepository.findById(USE_CAN_ID)).thenAnswer { Optional.of(savedUseCan) } + Mockito.`when`(useCanCalculateRepository.save(Mockito.any(UseCanCalculate::class.java))).thenAnswer { + invocation -> + invocation.getArgument(0).also(useCanCalculates::add) + } + Mockito.`when`(useCanCalculateRepository.findByUseCanIdAndStatus(USE_CAN_ID)).thenReturn(useCanCalculates) + } + } + + private class GiftApplicationFixture { + val giftRepository = Mockito.mock(GiftRepository::class.java) + val deliveryRepository = Mockito.mock(GiftDeliveryRepository::class.java) + val reviewRepository = Mockito.mock(GiftReviewRepository::class.java) + val categoryRepository = Mockito.mock(GiftCategoryRepository::class.java) + val sizePriceRepository = Mockito.mock(GiftSizePriceRepository::class.java) + val memberRepository = Mockito.mock(MemberRepository::class.java) + val applicationNoGenerator = Mockito.mock(GiftApplicationNoGenerator::class.java) + val canPaymentService = Mockito.mock(CanPaymentService::class.java) + val giftPushService = Mockito.mock(GiftPushService::class.java) + val sender = Member(password = "password", nickname = "sender", container = "aos").apply { id = SENDER_ID } + val creator = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID } + val category = GiftCategory("DOLL", "인형", false, true).apply { id = CATEGORY_ID } + val sizePrice = GiftSizePrice(GiftSize.SMALL, BASE_PRICE_CAN, SALE_PRICE_CAN, true) + lateinit var savedGift: Gift + lateinit var savedDelivery: GiftDelivery + lateinit var cancellationGift: Gift + lateinit var cancellationDelivery: GiftDelivery + lateinit var trackingGift: Gift + lateinit var trackingDelivery: GiftDelivery + lateinit var recipientAddressGift: Gift + lateinit var recipientAddressDelivery: GiftDelivery + lateinit var deliveryConfirmationGift: Gift + lateinit var deliveryConfirmationDelivery: GiftDelivery + lateinit var reviewGift: Gift + lateinit var savedReview: GiftReview + + val service = GiftCommandService( + giftRepository = giftRepository, + deliveryRepository = deliveryRepository, + reviewRepository = reviewRepository, + categoryRepository = categoryRepository, + sizePriceRepository = sizePriceRepository, + memberRepository = memberRepository, + applicationNoGenerator = applicationNoGenerator, + canPaymentService = canPaymentService, + giftPushService = giftPushService, + now = { NOW } + ) + + init { + setCreatorExists(true) + Mockito.`when`(categoryRepository.findById(CATEGORY_ID)).thenReturn(Optional.of(category)) + Mockito.`when`(sizePriceRepository.findBySizeCode(GiftSize.SMALL)).thenReturn(sizePrice) + Mockito.`when`(applicationNoGenerator.generate(NOW)).thenReturn(APPLICATION_NO) + Mockito.`when`(canPaymentService.spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos")).thenReturn(USE_CAN_ID) + Mockito.`when`(giftRepository.save(Mockito.any(Gift::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).apply { + id = GIFT_ID + savedGift = this + } + } + Mockito.`when`(deliveryRepository.save(Mockito.any(GiftDelivery::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).also { savedDelivery = it } + } + Mockito.`when`(reviewRepository.save(Mockito.any(GiftReview::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).apply { + id = REVIEW_ID + createdAt = NOW + savedReview = this + } + } + } + + fun setCreatorExists(exists: Boolean) { + Mockito.`when`(memberRepository.findCreatorByIdOrNull(RECIPIENT_ID)).thenReturn(creator.takeIf { exists }) + } + + fun prepareGiftForCancellation( + status: GiftStatus = GiftStatus.RECEIVED, + senderMemberId: Long = SENDER_ID + ) { + cancellationGift = Gift( + applicationNo = APPLICATION_NO, + senderMemberId = senderMemberId, + recipientMemberId = RECIPIENT_ID, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = SALE_PRICE_CAN, + canUsageId = USE_CAN_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW, + damageWaiverAgreed = true + ).apply { id = GIFT_ID } + cancellationDelivery = GiftDelivery( + giftId = GIFT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.plusDays(3) + ) + Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(cancellationGift) + Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(cancellationDelivery) + } + + fun prepareGiftForTracking( + status: GiftStatus = GiftStatus.RECEIVED, + senderMemberId: Long = SENDER_ID, + deliveryExists: Boolean = true + ) { + trackingGift = Gift( + applicationNo = APPLICATION_NO, + senderMemberId = senderMemberId, + recipientMemberId = RECIPIENT_ID, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = SALE_PRICE_CAN, + canUsageId = USE_CAN_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW, + damageWaiverAgreed = true + ).apply { id = GIFT_ID } + trackingDelivery = GiftDelivery( + giftId = GIFT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.plusDays(3) + ) + Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(trackingGift) + Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + trackingDelivery.takeIf { deliveryExists } + ) + } + + fun prepareGiftForRecipientAddress( + status: GiftStatus = GiftStatus.TRACKING_REGISTERED, + recipientMemberId: Long = RECIPIENT_ID, + deliveryExists: Boolean = true + ) { + recipientAddressGift = Gift( + applicationNo = APPLICATION_NO, + senderMemberId = SENDER_ID, + recipientMemberId = recipientMemberId, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = SALE_PRICE_CAN, + canUsageId = USE_CAN_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW, + damageWaiverAgreed = true + ).apply { id = GIFT_ID } + recipientAddressDelivery = GiftDelivery( + giftId = GIFT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.plusDays(3) + ) + Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(recipientAddressGift) + Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + recipientAddressDelivery.takeIf { deliveryExists } + ) + } + + fun prepareGiftForDeliveryConfirmation( + status: GiftStatus = GiftStatus.INSPECTION_COMPLETED, + recipientMemberId: Long = RECIPIENT_ID, + deliveryExists: Boolean = true + ) { + deliveryConfirmationGift = Gift( + applicationNo = APPLICATION_NO, + senderMemberId = SENDER_ID, + recipientMemberId = recipientMemberId, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = SALE_PRICE_CAN, + canUsageId = USE_CAN_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW, + damageWaiverAgreed = true + ).apply { id = GIFT_ID } + deliveryConfirmationDelivery = GiftDelivery( + giftId = GIFT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.plusDays(3) + ) + Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(deliveryConfirmationGift) + Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + deliveryConfirmationDelivery.takeIf { deliveryExists } + ) + } + + fun prepareGiftForReview( + status: GiftStatus = GiftStatus.DELIVERED, + senderMemberId: Long = SENDER_ID, + reviewExists: Boolean = false + ) { + reviewGift = Gift( + applicationNo = APPLICATION_NO, + senderMemberId = senderMemberId, + recipientMemberId = RECIPIENT_ID, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = SALE_PRICE_CAN, + canUsageId = USE_CAN_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW, + damageWaiverAgreed = true + ).apply { id = GIFT_ID } + Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(reviewGift) + Mockito.`when`(reviewRepository.findByGiftId(GIFT_ID)).thenReturn( + GiftReview(GIFT_ID, SENDER_ID, 5).takeIf { reviewExists } + ) + } + } + + data class InvalidGiftApplicationScenario( + val command: GiftApplicationCommand, + val categoryActive: Boolean = true, + val requiresDamageWaiver: Boolean = false, + val sizePriceActive: Boolean = true, + val creatorExists: Boolean = true + ) + + data class InvalidGiftCancellationScenario( + val requesterMemberId: Long, + val senderMemberId: Long, + val status: GiftStatus + ) + + data class InvalidGiftTrackingRegistrationScenario( + val requesterMemberId: Long = SENDER_ID, + val senderMemberId: Long = SENDER_ID, + val status: GiftStatus = GiftStatus.RECEIVED, + val command: GiftTrackingRegistrationCommand = validGiftTrackingRegistrationCommand(), + val deliveryExists: Boolean = true + ) + + data class InvalidGiftRecipientAddressRegistrationScenario( + val requesterMemberId: Long = RECIPIENT_ID, + val recipientMemberId: Long = RECIPIENT_ID, + val status: GiftStatus = GiftStatus.TRACKING_REGISTERED, + val command: GiftRecipientAddressRegistrationCommand = validGiftRecipientAddressRegistrationCommand(), + val deliveryExists: Boolean = true + ) + + data class InvalidGiftDeliveryConfirmationScenario( + val requesterMemberId: Long = RECIPIENT_ID, + val recipientMemberId: Long = RECIPIENT_ID, + val status: GiftStatus = GiftStatus.INSPECTION_COMPLETED, + val deliveryExists: Boolean = true + ) + + data class InvalidGiftReviewScenario( + val requesterMemberId: Long = SENDER_ID, + val senderMemberId: Long = SENDER_ID, + val status: GiftStatus = GiftStatus.DELIVERED, + val command: GiftReviewCommand = validGiftReviewCommand(), + val reviewExists: Boolean = false + ) + + private companion object { + const val MEMBER_ID = 1L + const val CHARGE_ID = 10L + const val USE_CAN_ID = 100L + const val INITIAL_CAN = 100 + const val GIFT_PRICE_CAN = 30 + const val SENDER_ID = 2L + const val OTHER_MEMBER_ID = 4L + const val RECIPIENT_ID = 3L + const val CATEGORY_ID = 4L + const val GIFT_ID = 5L + const val REVIEW_ID = 6L + const val APPLICATION_NO = "G20260929000001" + const val BASE_PRICE_CAN = 100 + const val SALE_PRICE_CAN = 80 + val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0) + + @JvmStatic + fun invalidGiftApplicationScenarios() = listOf( + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderName = "")), + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderPhoneNumber = "")), + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderZipCode = "")), + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderAddress = "")), + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderTermsAgreed = false)), + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderPrivacyAgreed = false)), + InvalidGiftApplicationScenario(validGiftApplicationCommand(), categoryActive = false), + InvalidGiftApplicationScenario( + validGiftApplicationCommand().copy(damageWaiverAgreed = false), + requiresDamageWaiver = true + ), + InvalidGiftApplicationScenario(validGiftApplicationCommand(), sizePriceActive = false), + InvalidGiftApplicationScenario(validGiftApplicationCommand(), creatorExists = false) + ) + + @JvmStatic + fun invalidGiftCancellationScenarios() = listOf( + InvalidGiftCancellationScenario(OTHER_MEMBER_ID, SENDER_ID, GiftStatus.RECEIVED), + InvalidGiftCancellationScenario(SENDER_ID, SENDER_ID, GiftStatus.TRACKING_REGISTERED) + ) + + @JvmStatic + fun invalidGiftTrackingRegistrationScenarios() = listOf( + InvalidGiftTrackingRegistrationScenario(requesterMemberId = OTHER_MEMBER_ID), + InvalidGiftTrackingRegistrationScenario(status = GiftStatus.TRACKING_REGISTERED), + InvalidGiftTrackingRegistrationScenario( + command = validGiftTrackingRegistrationCommand().copy(courierCompanyName = "") + ), + InvalidGiftTrackingRegistrationScenario( + command = validGiftTrackingRegistrationCommand().copy(courierCompanyName = "a".repeat(51)) + ), + InvalidGiftTrackingRegistrationScenario( + command = validGiftTrackingRegistrationCommand().copy(trackingNumber = "") + ), + InvalidGiftTrackingRegistrationScenario(deliveryExists = false) + ) + + @JvmStatic + fun recipientAddressRegistrableStatuses() = listOf( + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED + ) + + @JvmStatic + fun invalidGiftRecipientAddressRegistrationScenarios() = listOf( + InvalidGiftRecipientAddressRegistrationScenario(requesterMemberId = OTHER_MEMBER_ID), + InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.RECEIVED), + InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.DELIVERED), + InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.UNDELIVERABLE), + InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.CANCELED), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(name = "") + ), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(phoneNumber = "") + ), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(zipCode = "") + ), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(address = "") + ), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(recipientTermsAgreed = false) + ), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(recipientPrivacyAgreed = false) + ), + InvalidGiftRecipientAddressRegistrationScenario(deliveryExists = false) + ) + + @JvmStatic + fun invalidGiftDeliveryConfirmationScenarios() = listOf( + InvalidGiftDeliveryConfirmationScenario(requesterMemberId = OTHER_MEMBER_ID), + InvalidGiftDeliveryConfirmationScenario(status = GiftStatus.TRACKING_REGISTERED), + InvalidGiftDeliveryConfirmationScenario(status = GiftStatus.DELIVERED), + InvalidGiftDeliveryConfirmationScenario(deliveryExists = false) + ) + + @JvmStatic + fun invalidGiftReviewScenarios() = listOf( + InvalidGiftReviewScenario(requesterMemberId = OTHER_MEMBER_ID), + InvalidGiftReviewScenario(status = GiftStatus.INSPECTION_COMPLETED), + InvalidGiftReviewScenario(reviewExists = true), + InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(rating = 0)), + InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(rating = 6)), + InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(keywords = listOf("a".repeat(256)))), + InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(comment = "a".repeat(256))) + ) + + fun validGiftApplicationCommand() = GiftApplicationCommand( + recipientMemberId = RECIPIENT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + senderAddressDetail = "101동 1001호", + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + damageWaiverAgreed = true + ) + + fun validGiftTrackingRegistrationCommand() = GiftTrackingRegistrationCommand( + courierCompanyName = "CJ대한통운", + trackingNumber = "1234567890" + ) + + fun validGiftRecipientAddressRegistrationCommand() = GiftRecipientAddressRegistrationCommand( + name = "김소다", + phoneNumber = "01098765432", + zipCode = "04524", + address = "서울시 중구", + addressDetail = "202호", + recipientTermsAgreed = true, + recipientPrivacyAgreed = true + ) + + fun validGiftReviewCommand() = GiftReviewCommand( + rating = 5, + keywords = listOf("배송이 빨라요", "안내가 친절해요"), + comment = "선물 전달 과정이 만족스러웠습니다." + ) + } +}