|
|
@@ -0,0 +1,253 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.ProductOrder;
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
+import com.etotem.cfc.mapper.ProductOrderMapper;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 多渠道履约发货测试(规格:docs/superpowers/specs/2026-10-22-fulfillment-multi-mode-design.md §6)
|
|
|
+ */
|
|
|
+public class ProductOrderServiceFulfillmentMultiModeTest {
|
|
|
+
|
|
|
+ private static final String ORDER_NO = "PO20261022001";
|
|
|
+
|
|
|
+ private ProductOrderService orderService;
|
|
|
+ private ProductOrderMapper orderMapper;
|
|
|
+ private UserMapper userMapper;
|
|
|
+ private WechatService wechatService;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ public void setUp() {
|
|
|
+ orderService = new ProductOrderService();
|
|
|
+ orderMapper = mock(ProductOrderMapper.class);
|
|
|
+ userMapper = mock(UserMapper.class);
|
|
|
+ wechatService = mock(WechatService.class);
|
|
|
+ ReflectionTestUtils.setField(orderService, "orderMapper", orderMapper);
|
|
|
+ ReflectionTestUtils.setField(orderService, "userMapper", userMapper);
|
|
|
+ ReflectionTestUtils.setField(orderService, "wechatService", wechatService);
|
|
|
+ }
|
|
|
+
|
|
|
+ private ProductOrder paidOrder(Integer deliveryMethod, String productType) {
|
|
|
+ ProductOrder order = new ProductOrder();
|
|
|
+ order.setOrderNo(ORDER_NO);
|
|
|
+ order.setBuyerId(100L);
|
|
|
+ order.setProductName("测试商品");
|
|
|
+ order.setQuantity(1);
|
|
|
+ order.setStatus("paid");
|
|
|
+ order.setDeliveryMethod(deliveryMethod);
|
|
|
+ order.setProductType(productType);
|
|
|
+ return order;
|
|
|
+ }
|
|
|
+
|
|
|
+ private ProductOrder virtualPaidOrder() {
|
|
|
+ return paidOrder(1, "virtual");
|
|
|
+ }
|
|
|
+
|
|
|
+ private ProductOrder virtualCompletedOrder() {
|
|
|
+ ProductOrder order = virtualPaidOrder();
|
|
|
+ order.setStatus("completed");
|
|
|
+ return order;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void stubBuyerWithOpenid() {
|
|
|
+ User buyer = new User();
|
|
|
+ buyer.setOpenid("oABC123");
|
|
|
+ when(userMapper.selectById(100L)).thenReturn(buyer);
|
|
|
+ when(wechatService.uploadShippingInfo(any(), any(), any(), any(), any(), any(), any())).thenReturn(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void stubBuyerNoOpenid() {
|
|
|
+ User buyer = new User();
|
|
|
+ buyer.setOpenid("");
|
|
|
+ when(userMapper.selectById(100L)).thenReturn(buyer);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 1. 快递发货:缺单号/缺公司 → Result.error,不落库、不调微信 */
|
|
|
+ @Test
|
|
|
+ public void ship_express_requiresTrackingAndCompany() {
|
|
|
+ ProductOrder order = paidOrder(1, "physical");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(order);
|
|
|
+ stubBuyerWithOpenid();
|
|
|
+
|
|
|
+ // 缺单号
|
|
|
+ Result<String> r1 = orderService.ship(ORDER_NO, "express", null, "顺丰速运", null, null);
|
|
|
+ assertEquals("请填写物流单号", r1.getMessage());
|
|
|
+ // 缺公司
|
|
|
+ Result<String> r2 = orderService.ship(ORDER_NO, "express", "SF123", null, null, null);
|
|
|
+ assertEquals("请填写物流公司", r2.getMessage());
|
|
|
+
|
|
|
+ // 验证:无 updateById、无微信同步
|
|
|
+ verify(orderMapper, never()).updateById(any());
|
|
|
+ verify(wechatService, never()).uploadShippingInfo(any(), any(), any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 2. 同城发货 → 落库 pending_receipt + 微信同步 logisticsType=2 + 单号/公司透传 */
|
|
|
+ @Test
|
|
|
+ public void ship_local_syncsWechatWithLogisticsType2() {
|
|
|
+ ProductOrder order = paidOrder(6, "physical"); // deliveryMethod=6 同城配送
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(order);
|
|
|
+ stubBuyerWithOpenid();
|
|
|
+
|
|
|
+ Result<String> r = orderService.ship(ORDER_NO, "local", "FLASH123", "闪送", null, "13800138000");
|
|
|
+ assertEquals("已发货", r.getMessage());
|
|
|
+
|
|
|
+ // 落库:pending_receipt
|
|
|
+ ArgumentCaptor<ProductOrder> persisted = ArgumentCaptor.forClass(ProductOrder.class);
|
|
|
+ verify(orderMapper).updateById(persisted.capture());
|
|
|
+ ProductOrder saved = persisted.getValue();
|
|
|
+ assertEquals("FLASH123", saved.getLogisticsNo());
|
|
|
+ assertEquals("闪送", saved.getLogisticsCompany());
|
|
|
+ assertEquals("pending_receipt", saved.getStatus());
|
|
|
+
|
|
|
+ // 微信同步:logisticsType=2,单号/公司透传,receiverContact 透传
|
|
|
+ ArgumentCaptor<Integer> logisticsType = ArgumentCaptor.forClass(Integer.class);
|
|
|
+ ArgumentCaptor<String> trackingNo = ArgumentCaptor.forClass(String.class);
|
|
|
+ ArgumentCaptor<String> expressCompany = ArgumentCaptor.forClass(String.class);
|
|
|
+ ArgumentCaptor<String> receiverContact = ArgumentCaptor.forClass(String.class);
|
|
|
+ verify(wechatService).uploadShippingInfo(eq(ORDER_NO), eq("oABC123"),
|
|
|
+ trackingNo.capture(), expressCompany.capture(), any(),
|
|
|
+ logisticsType.capture(), receiverContact.capture());
|
|
|
+ assertEquals(2, logisticsType.getValue());
|
|
|
+ assertEquals("FLASH123", trackingNo.getValue());
|
|
|
+ assertEquals("闪送", expressCompany.getValue());
|
|
|
+ assertEquals("13800138000", receiverContact.getValue());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 3. 自提发货 → 无单号 + 微信 logisticsType=4;传入单号 → 拒绝 */
|
|
|
+ @Test
|
|
|
+ public void ship_selfPickup_syncsWechatWithLogisticsType4() {
|
|
|
+ ProductOrder order = paidOrder(2, "physical"); // deliveryMethod=2 自提
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(order);
|
|
|
+ stubBuyerWithOpenid();
|
|
|
+
|
|
|
+ // 正常自提:无单号、无公司
|
|
|
+ Result<String> r1 = orderService.ship(ORDER_NO, "selfPickup", null, null, null, null);
|
|
|
+ assertEquals("已发货", r1.getMessage());
|
|
|
+
|
|
|
+ // 微信同步:logisticsType=4,trackingNo/expressCompany 为 null
|
|
|
+ ArgumentCaptor<Integer> logisticsType = ArgumentCaptor.forClass(Integer.class);
|
|
|
+ ArgumentCaptor<String> trackingNo = ArgumentCaptor.forClass(String.class);
|
|
|
+ ArgumentCaptor<String> expressCompany = ArgumentCaptor.forClass(String.class);
|
|
|
+ verify(wechatService).uploadShippingInfo(eq(ORDER_NO), eq("oABC123"),
|
|
|
+ trackingNo.capture(), expressCompany.capture(), any(),
|
|
|
+ logisticsType.capture(), any());
|
|
|
+ assertEquals(4, logisticsType.getValue());
|
|
|
+ assertEquals(null, trackingNo.getValue());
|
|
|
+ assertEquals(null, expressCompany.getValue());
|
|
|
+
|
|
|
+ // 传入单号 → 拒绝
|
|
|
+ Result<String> r2 = orderService.ship(ORDER_NO, "selfPickup", "ILLEGAL123", null, null, null);
|
|
|
+ assertEquals("自提订单无需填写物流信息", r2.getMessage());
|
|
|
+
|
|
|
+ // 传入公司 → 拒绝
|
|
|
+ Result<String> r3 = orderService.ship(ORDER_NO, "selfPickup", null, "非法公司", null, null);
|
|
|
+ assertEquals("自提订单无需填写物流信息", r3.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 4. 虚拟商品:传入单号/公司拒绝;补发货幂等(completed 后重复调用返回成功提示、不重复同步微信) */
|
|
|
+ @Test
|
|
|
+ public void ship_virtual_rejectsManualShipAndIdempotent() {
|
|
|
+ // 4a. 虚拟商品 paid 状态:显式传单号/公司 → 拒绝
|
|
|
+ ProductOrder virtualPaid = virtualPaidOrder();
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(virtualPaid);
|
|
|
+ stubBuyerWithOpenid();
|
|
|
+
|
|
|
+ Result<String> r1 = orderService.ship(ORDER_NO, "virtual", "SF123", "顺丰", null, null);
|
|
|
+ assertEquals("虚拟商品无需填写物流信息", r1.getMessage());
|
|
|
+
|
|
|
+ // 4b. 虚拟商品 paid 状态:正常补发货(走 autoShipVirtualOrder)
|
|
|
+ Result<String> r2 = orderService.ship(ORDER_NO, "virtual", null, null, null, null);
|
|
|
+ assertEquals("自动发货成功", r2.getMessage());
|
|
|
+ // 状态变为 completed
|
|
|
+ ArgumentCaptor<ProductOrder> persisted = ArgumentCaptor.forClass(ProductOrder.class);
|
|
|
+ verify(orderMapper, org.mockito.Mockito.atLeastOnce()).updateById(persisted.capture());
|
|
|
+ ProductOrder saved = persisted.getValue();
|
|
|
+ assertEquals("completed", saved.getStatus());
|
|
|
+
|
|
|
+ // 微信同步:logisticsType=3(虚拟商品),无单号/公司
|
|
|
+ ArgumentCaptor<Integer> logisticsType = ArgumentCaptor.forClass(Integer.class);
|
|
|
+ ArgumentCaptor<String> trackingNo = ArgumentCaptor.forClass(String.class);
|
|
|
+ ArgumentCaptor<String> expressCompany = ArgumentCaptor.forClass(String.class);
|
|
|
+ verify(wechatService).uploadShippingInfo(eq(ORDER_NO), eq("oABC123"),
|
|
|
+ trackingNo.capture(), expressCompany.capture(), any(),
|
|
|
+ logisticsType.capture(), any());
|
|
|
+ assertEquals(3, logisticsType.getValue());
|
|
|
+ assertEquals(null, trackingNo.getValue());
|
|
|
+ assertEquals(null, expressCompany.getValue());
|
|
|
+
|
|
|
+ // 4c. 虚拟商品已 completed:再次调用 → 幂等返回提示、不再同步微信
|
|
|
+ org.mockito.Mockito.clearInvocations(wechatService);
|
|
|
+ ProductOrder virtualCompleted = virtualCompletedOrder();
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(virtualCompleted);
|
|
|
+ Result<String> r3 = orderService.ship(ORDER_NO, "virtual", null, null, null, null);
|
|
|
+ assertEquals("该订单已自动交付", r3.getMessage());
|
|
|
+ // 清空后本次不应再调用微信同步(幂等)
|
|
|
+ verify(wechatService, never())
|
|
|
+ .uploadShippingInfo(any(), any(), any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 5. 虚拟商品自动发货:支付回调后 → status=completed + 微信 logisticsType=3 + 无单号;
|
|
|
+ * 买家无 openid 时 fail-open 不抛异常 */
|
|
|
+ @Test
|
|
|
+ public void autoShipVirtualOrder_onPaySuccess() {
|
|
|
+ // 5a. 有 openid 正常同步微信
|
|
|
+ ProductOrder virtualPaid = virtualPaidOrder();
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(virtualPaid);
|
|
|
+ stubBuyerWithOpenid();
|
|
|
+
|
|
|
+ // 直接调用私有 autoShipVirtualOrder(通过反射)
|
|
|
+ Result<?> r = ReflectionTestUtils.invokeMethod(orderService, "autoShipVirtualOrder", virtualPaid);
|
|
|
+ assertNotNull(r);
|
|
|
+ assertEquals("自动发货成功", r.getMessage());
|
|
|
+
|
|
|
+ // 状态变为 completed
|
|
|
+ ArgumentCaptor<ProductOrder> persisted = ArgumentCaptor.forClass(ProductOrder.class);
|
|
|
+ verify(orderMapper).updateById(persisted.capture());
|
|
|
+ assertEquals("completed", persisted.getValue().getStatus());
|
|
|
+
|
|
|
+ // 微信同步:logisticsType=3
|
|
|
+ ArgumentCaptor<Integer> logisticsType = ArgumentCaptor.forClass(Integer.class);
|
|
|
+ verify(wechatService).uploadShippingInfo(eq(ORDER_NO), eq("oABC123"),
|
|
|
+ any(), any(), any(), logisticsType.capture(), any());
|
|
|
+ assertEquals(3, logisticsType.getValue());
|
|
|
+
|
|
|
+ // 5b. 买家无 openid:fail-open,不抛异常,不阻塞
|
|
|
+ org.mockito.Mockito.clearInvocations(wechatService);
|
|
|
+ ProductOrder virtualPaid2 = virtualPaidOrder();
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(virtualPaid2);
|
|
|
+ stubBuyerNoOpenid();
|
|
|
+ Result<?> r2 = ReflectionTestUtils.invokeMethod(orderService, "autoShipVirtualOrder", virtualPaid2);
|
|
|
+ assertNotNull(r2);
|
|
|
+ assertEquals("自动发货成功", r2.getMessage());
|
|
|
+ // 买家无 openid 时不应调用微信
|
|
|
+ verify(wechatService, never())
|
|
|
+ .uploadShippingInfo(any(), any(), any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 6. deliveryMethod=3(两者皆可)不显式传 shipType → 明确错误 */
|
|
|
+ @Test
|
|
|
+ public void resolveShipType_deliveryMethod3_requiresExplicitShipType() {
|
|
|
+ ProductOrder order = paidOrder(3, "physical"); // 两者皆可
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(order);
|
|
|
+ stubBuyerWithOpenid();
|
|
|
+
|
|
|
+ // 不传 shipType(null) → 应返回明确错误
|
|
|
+ Result<String> r = orderService.ship(ORDER_NO, null, "SF123", "顺丰", null, null);
|
|
|
+ assertEquals("无法判断发货方式,请选择:快递发货或自提发货", r.getMessage());
|
|
|
+ }
|
|
|
+}
|