package com.tourguide.modules.order.service; import com.tourguide.common.exception.BusinessException; import com.tourguide.modules.order.domain.OrderStatus; import com.tourguide.modules.order.entity.TourOrder; import com.tourguide.modules.order.entity.TourOrderPaymentAttempt; import com.tourguide.modules.order.mapper.TourOrderMapper; import com.tourguide.modules.order.mapper.TourOrderPaymentAttemptMapper; import com.tourguide.modules.order.payment.HuifuPaymentNotice; import com.tourguide.modules.order.payment.OrderPaymentPreparation; 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.assertj.core.api.Assertions.assertThatThrownBy; 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 TourOrderPaymentServiceTest { private TourOrderMapper orderMapper; private TourOrderPaymentAttemptMapper paymentAttemptMapper; private GuideOrderMatchingService matchingService; private TourOrderPaymentService paymentService; @BeforeEach void setUp() { orderMapper = mock(TourOrderMapper.class); paymentAttemptMapper = mock(TourOrderPaymentAttemptMapper.class); matchingService = mock(GuideOrderMatchingService.class); paymentService = new TourOrderPaymentService(orderMapper, paymentAttemptMapper, matchingService); } @Test void repeatedPaymentShouldCreateNewRequestSequenceAndSyncOrder() { TourOrder order = pendingOrder(); when(orderMapper.selectForUpdate(99L)).thenReturn(order); OrderPaymentPreparation first = paymentService.prepare(7L, 99L); OrderPaymentPreparation second = paymentService.prepare(7L, 99L); assertThat(first.getReqSeqId()).startsWith("PY").hasSize(32); assertThat(second.getReqSeqId()).startsWith("PY").hasSize(32); assertThat(second.getReqSeqId()).isNotEqualTo(first.getReqSeqId()); assertThat(order.getPaymentOrderNo()).isEqualTo(second.getReqSeqId()); assertThat(order.getPaymentMethod()).isEqualTo("WECHAT"); ArgumentCaptor captor = ArgumentCaptor.forClass(TourOrderPaymentAttempt.class); verify(paymentAttemptMapper, times(2)).insert(captor.capture()); assertThat(captor.getAllValues()).extracting(TourOrderPaymentAttempt::getReqSeqId) .containsExactly(first.getReqSeqId(), second.getReqSeqId()); assertThat(captor.getAllValues()).allSatisfy(attempt -> { assertThat(attempt.getOrderId()).isEqualTo(99L); assertThat(attempt.getAmount()).isEqualByComparingTo("36.00"); assertThat(attempt.getStatus()).isEqualTo("CREATED"); }); verify(orderMapper, times(2)).updateById(order); } @Test void successCallbackShouldConfirmAttemptOnceAndStartMatching() { TourOrder order = pendingOrder(); order.setPaymentOrderNo("PY-newer"); TourOrderPaymentAttempt attempt = attempt("PY-paid", "CREATED"); when(paymentAttemptMapper.selectByReqSeqIdForUpdate("PY-paid")).thenReturn(attempt); when(orderMapper.selectForUpdate(99L)).thenReturn(order); HuifuPaymentNotice notice = notice("PY-paid", new BigDecimal("36.00")); boolean confirmed = paymentService.confirm(notice); assertThat(confirmed).isTrue(); assertThat(attempt.getStatus()).isEqualTo("SUCCEEDED"); assertThat(attempt.getProviderTransactionId()).isEqualTo("hf-seq-1"); assertThat(order.getPaymentOrderNo()).isEqualTo("PY-paid"); assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING_ACCEPT.getCode()); assertThat(order.getPaymentAmount()).isEqualByComparingTo("36.00"); assertThat(order.getPaymentTime()).isEqualTo(notice.getPaidAt()); verify(paymentAttemptMapper).updateById(attempt); verify(orderMapper).updateById(order); verify(matchingService).match(99L); } @Test void duplicateCallbackShouldNotMatchAgain() { TourOrder order = pendingOrder(); order.setPaymentTime(LocalDateTime.now()); order.setStatus(OrderStatus.PENDING_ACCEPT.getCode()); TourOrderPaymentAttempt attempt = attempt("PY-paid", "SUCCEEDED"); when(paymentAttemptMapper.selectByReqSeqIdForUpdate("PY-paid")).thenReturn(attempt); when(orderMapper.selectForUpdate(99L)).thenReturn(order); boolean confirmed = paymentService.confirm(notice("PY-paid", new BigDecimal("36.00"))); assertThat(confirmed).isFalse(); verify(paymentAttemptMapper, never()).updateById(attempt); verify(orderMapper, never()).updateById(order); verify(matchingService, never()).match(99L); } @Test void laterSuccessfulAttemptShouldBeRecordedWithoutDuplicateMatching() { TourOrder order = pendingOrder(); order.setPaymentTime(LocalDateTime.now()); order.setStatus(OrderStatus.PENDING_ACCEPT.getCode()); TourOrderPaymentAttempt attempt = attempt("PY-late", "CREATED"); when(paymentAttemptMapper.selectByReqSeqIdForUpdate("PY-late")).thenReturn(attempt); when(orderMapper.selectForUpdate(99L)).thenReturn(order); boolean confirmed = paymentService.confirm(notice("PY-late", new BigDecimal("36.00"))); assertThat(confirmed).isFalse(); assertThat(attempt.getStatus()).isEqualTo("SUCCEEDED"); verify(paymentAttemptMapper).updateById(attempt); verify(orderMapper, never()).updateById(order); verify(matchingService, never()).match(99L); } @Test void callbackShouldRejectMismatchedAmount() { TourOrder order = pendingOrder(); TourOrderPaymentAttempt attempt = attempt("PY-paid", "CREATED"); when(paymentAttemptMapper.selectByReqSeqIdForUpdate("PY-paid")).thenReturn(attempt); when(orderMapper.selectForUpdate(99L)).thenReturn(order); assertThatThrownBy(() -> paymentService.confirm(notice("PY-paid", new BigDecimal("35.99")))) .isInstanceOf(BusinessException.class) .extracting("code") .isEqualTo(409); verify(paymentAttemptMapper, never()).updateById(attempt); verify(orderMapper, never()).updateById(order); verify(matchingService, never()).match(99L); } private TourOrder pendingOrder() { TourOrder order = new TourOrder(); order.setId(99L); order.setOrderNo("MJ202608030001"); order.setVisitorId(7L); order.setScenicName("Qingcheng Mountain"); order.setPackageName("Group tour"); order.setAmount(new BigDecimal("36.00")); order.setStatus(OrderStatus.PENDING_PAYMENT.getCode()); return order; } private TourOrderPaymentAttempt attempt(String reqSeqId, String status) { TourOrderPaymentAttempt attempt = new TourOrderPaymentAttempt(); attempt.setId(1L); attempt.setOrderId(99L); attempt.setReqSeqId(reqSeqId); attempt.setReqDate("20260803"); attempt.setAmount(new BigDecimal("36.00")); attempt.setStatus(status); return attempt; } private HuifuPaymentNotice notice(String reqSeqId, BigDecimal amount) { return new HuifuPaymentNotice(reqSeqId, "merchant-1", "S", amount, "hf-seq-1", LocalDateTime.of(2026, 8, 3, 16, 0)); } }