package com.tourguide.modules.order.service; import com.tourguide.modules.order.domain.OrderStatus; import com.tourguide.modules.order.domain.RefundStatus; import com.tourguide.modules.order.domain.PaymentMethod; import com.tourguide.modules.order.entity.TourOrder; import com.tourguide.modules.order.entity.TourOrderRefund; import com.tourguide.modules.order.mapper.TourOrderMapper; import com.tourguide.modules.order.mapper.TourOrderRefundMapper; import com.tourguide.modules.order.payment.PaymentRefundGateway; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import java.math.BigDecimal; import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class OrderRefundServiceTest { private TourOrderMapper orderMapper; private PaymentRefundGateway refundGateway; private OrderRefundService refundService; @BeforeEach void setUp() { orderMapper = mock(TourOrderMapper.class); refundGateway = mock(PaymentRefundGateway.class); refundService = new OrderRefundService(orderMapper, refundGateway); } @Test void paidCancellationShouldSubmitRefundOnce() { TourOrder order = paidOrder(); when(refundGateway.refund(org.mockito.ArgumentMatchers.eq(order), org.mockito.ArgumentMatchers.eq(new BigDecimal("36.00")))) .thenAnswer(invocation -> order.getRefundOrderNo()); boolean accepted = refundService.requestRefund(order); boolean duplicateAccepted = refundService.requestRefund(order); assertThat(accepted).isTrue(); assertThat(duplicateAccepted).isTrue(); assertThat(order.getRefundStatus()).isEqualTo(RefundStatus.REFUNDING.getCode()); assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED.getCode()); assertThat(order.getRefundOrderNo()).startsWith("RF").hasSize(32); assertThat(order.getRefundAmount()).isEqualByComparingTo("36.00"); assertThat(order.getRefundMethod()).isEqualTo("WECHAT"); verify(refundGateway, times(1)).refund(order, new BigDecimal("36.00")); verify(orderMapper, times(2)).updateById(order); } @Test void gatewayFailureShouldRecordRefundFailure() { TourOrder order = paidOrder(); when(refundGateway.refund(org.mockito.ArgumentMatchers.eq(order), org.mockito.ArgumentMatchers.any(BigDecimal.class))) .thenThrow(new IllegalStateException("gateway unavailable")); boolean accepted = refundService.requestRefund(order); assertThat(accepted).isFalse(); assertThat(order.getRefundStatus()).isEqualTo(RefundStatus.REFUND_FAILED.getCode()); verify(orderMapper, times(2)).updateById(order); } @Test void unpaidOrderShouldNotRequestRefund() { TourOrder order = paidOrder(); order.setPaymentTime(null); refundService.requestRefund(order); verify(refundGateway, never()).refund(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } @Test void temporaryBypassRefundShouldCompleteWithoutCallingGateway() { TourOrder order = paidOrder(); order.setPaymentMethod(PaymentMethod.TEMPORARY_BYPASS.getCode()); order.setPaymentOrderNo("TEMPMJ202608030001"); refundService.requestRefund(order); assertThat(order.getRefundStatus()).isEqualTo(RefundStatus.REFUNDED.getCode()); assertThat(order.getStatus()).isEqualTo(OrderStatus.REFUNDED.getCode()); assertThat(order.getRefundTime()).isNotNull(); assertThat(order.getRefundMethod()).isEqualTo(PaymentMethod.TEMPORARY_BYPASS.getCode()); verify(refundGateway, never()).refund(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); verify(orderMapper).updateById(order); } @Test void adminRefundShouldSubmitRequestedAmount() { TourOrder order = paidOrder(); when(refundGateway.refund(eq(order), eq(new BigDecimal("20.00")))) .thenAnswer(invocation -> order.getRefundOrderNo()); boolean accepted = refundService.requestRefund(order, new BigDecimal("20.00")); assertThat(accepted).isTrue(); assertThat(order.getRefundAmount()).isEqualByComparingTo("20.00"); verify(refundGateway).refund(order, new BigDecimal("20.00")); } @Test void followUpRefundShouldOnlyUseRemainingAmount() { TourOrder order = paidOrder(); order.setStatus(OrderStatus.CANCELLED.getCode()); order.setRefundStatus(RefundStatus.PARTIAL.getCode()); order.setRefundedAmount(new BigDecimal("20.00")); when(refundGateway.refund(eq(order), eq(new BigDecimal("16.00")))) .thenAnswer(invocation -> order.getRefundOrderNo()); boolean accepted = refundService.requestRefund(order, new BigDecimal("16.00")); assertThat(accepted).isTrue(); assertThat(order.getRefundAmount()).isEqualByComparingTo("16.00"); assertThat(order.getRefundStatus()).isEqualTo(RefundStatus.REFUNDING.getCode()); verify(refundGateway).refund(order, new BigDecimal("16.00")); } @Test void eachSuccessfulLocalRefundShouldCreateAnIndependentCompletedRecord() { TourOrderRefundMapper refundMapper = mock(TourOrderRefundMapper.class); refundService = new OrderRefundService(orderMapper, refundGateway, refundMapper); TourOrder order = paidOrder(); order.setPaymentMethod(PaymentMethod.TEMPORARY_BYPASS.getCode()); assertThat(refundService.requestRefund(order, new BigDecimal("20.00"))).isTrue(); assertThat(refundService.requestRefund(order, new BigDecimal("16.00"))).isTrue(); ArgumentCaptor insertCaptor = ArgumentCaptor.forClass(TourOrderRefund.class); verify(refundMapper, times(2)).insert(insertCaptor.capture()); assertThat(insertCaptor.getAllValues()).extracting(TourOrderRefund::getRefundAmount) .containsExactly(new BigDecimal("20.00"), new BigDecimal("16.00")); verify(refundMapper, times(2)).updateById(any(TourOrderRefund.class)); assertThat(insertCaptor.getAllValues()).extracting(TourOrderRefund::getRefundStatus) .containsOnly(RefundStatus.REFUNDED.getCode()); } @Test void refundShouldRejectAmountAboveRemainingBalance() { TourOrder order = paidOrder(); order.setRefundedAmount(new BigDecimal("20.00")); boolean accepted = refundService.requestRefund(order, new BigDecimal("17.00")); assertThat(accepted).isFalse(); verify(refundGateway, never()).refund(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } private TourOrder paidOrder() { TourOrder order = new TourOrder(); order.setId(99L); order.setOrderNo("MJ202608030001"); order.setPaymentOrderNo("PY202608031700000000000000000001"); order.setPaymentMethod("WECHAT"); order.setAmount(new BigDecimal("40.00")); order.setPaymentAmount(new BigDecimal("36.00")); order.setPaymentTime(LocalDateTime.now()); order.setStatus(OrderStatus.COMPLETED.getCode()); order.setRefundStatus(RefundStatus.NONE.getCode()); return order; } }