|
|
@@ -0,0 +1,314 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.dto.CreateProductOrderDTO;
|
|
|
+import com.etotem.cfc.dto.OrderItemVO;
|
|
|
+import com.etotem.cfc.dto.ProductOrderDTO;
|
|
|
+import com.etotem.cfc.entity.Product;
|
|
|
+import com.etotem.cfc.entity.ProductOrder;
|
|
|
+import com.etotem.cfc.entity.SpecialPriceDeal;
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
+import com.etotem.cfc.mapper.AfterSalesRequestMapper;
|
|
|
+import com.etotem.cfc.mapper.ProductMapper;
|
|
|
+import com.etotem.cfc.mapper.ProductOrderMapper;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.service.CouponService;
|
|
|
+import com.etotem.cfc.service.InventoryService;
|
|
|
+import com.etotem.cfc.service.PaymentService;
|
|
|
+import com.etotem.cfc.service.ProductGiftRuleService;
|
|
|
+import com.etotem.cfc.service.ProductOrderService;
|
|
|
+import com.etotem.cfc.service.ProductService;
|
|
|
+import com.etotem.cfc.service.SpecialPriceService;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyInt;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:商城下单 shop.order.create —— 特价下单分支(specialDealId)。
|
|
|
+ *
|
|
|
+ * <p>契约来源:docs/superpowers/api/API_REFERENCE.md §4.8 商品与订单 / §4.53 限时特价「下单(特价路径)」:
|
|
|
+ * <blockquote>
|
|
|
+ * POST /api/product/order/create 请求体新增 specialDealId(Long,可选)。若 specialDealId != null 走特价下单分支:
|
|
|
+ * 1. 校验活动存在、status=active、NOW() ∈ [start_time, end_time]、商品一致;
|
|
|
+ * 2. 每人限购校验(countByDeal 累计 < perUserLimit);
|
|
|
+ * 3. allowCoupon=0 且 couponId 非空 → 拒绝;
|
|
|
+ * 4. 原子扣量 consumeQuota(id, qty);
|
|
|
+ * 5. insert 订单行 special_deal_id = dealId,unit_price = specialPrice;
|
|
|
+ * 6. 回滚挂载点:cancel / approveRefund / OrderTimeoutCancelTask 调用 releaseQuota。
|
|
|
+ * </blockquote></p>
|
|
|
+ *
|
|
|
+ * <p>溯源需求:tests/user-scenarios/应用场景总览.md SC-P-54(加购/下单)、SC-P-55(订单支付),业务域 F 商城与会员。</p>
|
|
|
+ *
|
|
|
+ * <p>纯 Mockito + 反射注入,无 Spring 上下文 / 无 DB / 无外部服务(与同批 TeamProfitHookContractTest 风格一致)。</p>
|
|
|
+ */
|
|
|
+class ShopOrderCreateSpecialDealContractTest {
|
|
|
+
|
|
|
+ private static final long DEAL_ID = 1L;
|
|
|
+ private static final long PRODUCT_ID = 100L;
|
|
|
+ private static final long BUYER_ID = 200L;
|
|
|
+ private static final String ORDER_NO = "PO20261107S";
|
|
|
+
|
|
|
+ private ProductOrderService service;
|
|
|
+ private SpecialPriceService specialPriceService;
|
|
|
+ private ProductMapper productMapper;
|
|
|
+ private UserMapper userMapper;
|
|
|
+ private ProductOrderMapper orderMapper;
|
|
|
+ private ProductService productService;
|
|
|
+ private InventoryService inventoryService;
|
|
|
+ private CouponService couponService;
|
|
|
+ private ProductGiftRuleService productGiftRuleService;
|
|
|
+ private PaymentService paymentService;
|
|
|
+ private AfterSalesRequestMapper afterSalesRequestMapper;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ service = new ProductOrderService();
|
|
|
+ specialPriceService = mock(SpecialPriceService.class);
|
|
|
+ productMapper = mock(ProductMapper.class);
|
|
|
+ userMapper = mock(UserMapper.class);
|
|
|
+ orderMapper = mock(ProductOrderMapper.class);
|
|
|
+ productService = mock(ProductService.class);
|
|
|
+ inventoryService = mock(InventoryService.class);
|
|
|
+ couponService = mock(CouponService.class);
|
|
|
+ productGiftRuleService = mock(ProductGiftRuleService.class);
|
|
|
+ paymentService = mock(PaymentService.class);
|
|
|
+ afterSalesRequestMapper = mock(AfterSalesRequestMapper.class);
|
|
|
+
|
|
|
+ inject(service, "specialPriceService", specialPriceService);
|
|
|
+ inject(service, "productMapper", productMapper);
|
|
|
+ inject(service, "userMapper", userMapper);
|
|
|
+ inject(service, "orderMapper", orderMapper);
|
|
|
+ inject(service, "productService", productService);
|
|
|
+ inject(service, "inventoryService", inventoryService);
|
|
|
+ inject(service, "couponService", couponService);
|
|
|
+ inject(service, "productGiftRuleService", productGiftRuleService);
|
|
|
+ inject(service, "paymentService", paymentService);
|
|
|
+ inject(service, "afterSalesRequestMapper", afterSalesRequestMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void inject(Object target, String field, Object value) throws Exception {
|
|
|
+ Field f = target.getClass().getDeclaredField(field);
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(target, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== helpers ====================
|
|
|
+
|
|
|
+ private SpecialPriceDeal activeDeal(int sold, int perUserLimit, int allowCoupon) {
|
|
|
+ SpecialPriceDeal deal = new SpecialPriceDeal();
|
|
|
+ deal.setId(DEAL_ID);
|
|
|
+ deal.setProductId(PRODUCT_ID);
|
|
|
+ deal.setName("限时特价活动");
|
|
|
+ deal.setSpecialPrice(9900);
|
|
|
+ deal.setStartTime(new Date(System.currentTimeMillis() - 86400000L));
|
|
|
+ deal.setEndTime(new Date(System.currentTimeMillis() + 86400000L));
|
|
|
+ deal.setTotalQuantity(100);
|
|
|
+ deal.setSoldQuantity(sold);
|
|
|
+ deal.setPerUserLimit(perUserLimit);
|
|
|
+ deal.setAllowCoupon(allowCoupon);
|
|
|
+ deal.setStatus("active");
|
|
|
+ return deal;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Product onShelfProduct() {
|
|
|
+ Product product = new Product();
|
|
|
+ product.setId(PRODUCT_ID);
|
|
|
+ product.setName("特价测试商品");
|
|
|
+ product.setPrice(19800);
|
|
|
+ product.setProductType("physical");
|
|
|
+ product.setStatus("on_shelf");
|
|
|
+ product.setStock(100);
|
|
|
+ return product;
|
|
|
+ }
|
|
|
+
|
|
|
+ private User buyer() {
|
|
|
+ User user = new User();
|
|
|
+ user.setId(BUYER_ID);
|
|
|
+ user.setFamilyId(1L);
|
|
|
+ return user;
|
|
|
+ }
|
|
|
+
|
|
|
+ private CreateProductOrderDTO specialDealDTO(int quantity) {
|
|
|
+ CreateProductOrderDTO dto = new CreateProductOrderDTO();
|
|
|
+ dto.setProductId(PRODUCT_ID);
|
|
|
+ dto.setQuantity(quantity);
|
|
|
+ dto.setSpecialDealId(DEAL_ID);
|
|
|
+ dto.setPaymentMethod("wechat");
|
|
|
+ dto.setAddressSnapshot("{\"receiverName\":\"张三\",\"phone\":\"13800000000\"}");
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:特价下单成功,单价=特价、状态待支付、扣量并入库")
|
|
|
+ void 正常流_特价下单_按特价计价并扣量入库() {
|
|
|
+ when(specialPriceService.getById(DEAL_ID)).thenReturn(activeDeal(0, 2, 0));
|
|
|
+ when(specialPriceService.getUserPurchased(DEAL_ID, BUYER_ID)).thenReturn(0);
|
|
|
+ when(productMapper.selectById(PRODUCT_ID)).thenReturn(onShelfProduct());
|
|
|
+ when(userMapper.selectById(BUYER_ID)).thenReturn(buyer());
|
|
|
+ when(specialPriceService.consumeQuota(DEAL_ID, 2)).thenReturn(true);
|
|
|
+
|
|
|
+ Result<ProductOrderDTO> result = service.create(specialDealDTO(2), BUYER_ID);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode(), "特价下单应成功");
|
|
|
+ assertNotNull(result.getData(), "应返回订单数据");
|
|
|
+ assertEquals("pending", result.getData().getStatus(), "新订单应为待支付");
|
|
|
+ assertEquals(9900, result.getData().getUnitPrice(), "单价应为特价");
|
|
|
+ assertEquals(19800, result.getData().getTotalAmount(), "总价应为特价×数量");
|
|
|
+ verify(specialPriceService).consumeQuota(DEAL_ID, 2);
|
|
|
+ verify(orderMapper).insert(any(ProductOrder.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 异常流 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-1:活动不存在 → 特价活动不存在")
|
|
|
+ void 异常流_活动不存在() {
|
|
|
+ when(specialPriceService.getById(DEAL_ID)).thenReturn(null);
|
|
|
+
|
|
|
+ Result<ProductOrderDTO> result = service.create(specialDealDTO(2), BUYER_ID);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("特价活动不存在"), "实际: " + result.getMessage());
|
|
|
+ verify(orderMapper, never()).insert(any(ProductOrder.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-2:活动未开始 → 拒绝")
|
|
|
+ void 异常流_未开始活动() {
|
|
|
+ SpecialPriceDeal deal = activeDeal(0, 2, 0);
|
|
|
+ deal.setStartTime(new Date(System.currentTimeMillis() + 86400000L));
|
|
|
+ when(specialPriceService.getById(DEAL_ID)).thenReturn(deal);
|
|
|
+
|
|
|
+ Result<ProductOrderDTO> result = service.create(specialDealDTO(2), BUYER_ID);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("未开始"), "实际: " + result.getMessage());
|
|
|
+ verify(orderMapper, never()).insert(any(ProductOrder.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-3:活动已结束 → 拒绝")
|
|
|
+ void 异常流_已结束活动() {
|
|
|
+ SpecialPriceDeal deal = activeDeal(0, 2, 0);
|
|
|
+ deal.setEndTime(new Date(System.currentTimeMillis() - 86400000L));
|
|
|
+ when(specialPriceService.getById(DEAL_ID)).thenReturn(deal);
|
|
|
+
|
|
|
+ Result<ProductOrderDTO> result = service.create(specialDealDTO(2), BUYER_ID);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("已结束"), "实际: " + result.getMessage());
|
|
|
+ verify(orderMapper, never()).insert(any(ProductOrder.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-4:每人限购超限 → 先查再扣,不扣量不插入")
|
|
|
+ void 异常流_每人限购超限() {
|
|
|
+ when(specialPriceService.getById(DEAL_ID)).thenReturn(activeDeal(0, 2, 0));
|
|
|
+ when(specialPriceService.getUserPurchased(DEAL_ID, BUYER_ID)).thenReturn(5);
|
|
|
+
|
|
|
+ Result<ProductOrderDTO> result = service.create(specialDealDTO(2), BUYER_ID);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("每人限购2件"), "实际: " + result.getMessage());
|
|
|
+ verify(specialPriceService, never()).consumeQuota(any(), anyInt());
|
|
|
+ verify(orderMapper, never()).insert(any(ProductOrder.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-5:allowCoupon=0 且带 couponId → 拒绝")
|
|
|
+ void 异常流_优惠券不允许() {
|
|
|
+ when(specialPriceService.getById(DEAL_ID)).thenReturn(activeDeal(0, 2, 0));
|
|
|
+ when(specialPriceService.getUserPurchased(DEAL_ID, BUYER_ID)).thenReturn(0);
|
|
|
+ CreateProductOrderDTO dto = specialDealDTO(2);
|
|
|
+ dto.setCouponId(9L);
|
|
|
+
|
|
|
+ Result<ProductOrderDTO> result = service.create(dto, BUYER_ID);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("不可使用优惠券"), "实际: " + result.getMessage());
|
|
|
+ verify(specialPriceService, never()).consumeQuota(any(), anyInt());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-6:特价仅支持单商品直购,带 items → 拒绝")
|
|
|
+ void 异常流_特价不支持购物车多商品() {
|
|
|
+ CreateProductOrderDTO dto = specialDealDTO(2);
|
|
|
+ dto.setItems(Collections.singletonList(new OrderItemVO()));
|
|
|
+
|
|
|
+ Result<ProductOrderDTO> result = service.create(dto, BUYER_ID);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("单独购买"), "实际: " + result.getMessage());
|
|
|
+ verify(specialPriceService, never()).consumeQuota(any(), anyInt());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流-1:未登录特价下单 → 请先登录")
|
|
|
+ void 权限流_未登录特价下单() {
|
|
|
+ Result<ProductOrderDTO> result = service.create(specialDealDTO(2), null);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode());
|
|
|
+ assertTrue(result.getMessage().contains("请先登录"), "实际: " + result.getMessage());
|
|
|
+ verify(orderMapper, never()).insert(any(ProductOrder.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 回滚挂载点 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("回滚流-1:取消特价订单 → releaseQuota 释放额度")
|
|
|
+ void 回滚流_取消订单释放额度() {
|
|
|
+ ProductOrder order = specialDealOrder();
|
|
|
+ order.setStatus("pending");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(order);
|
|
|
+
|
|
|
+ Result<String> result = service.cancel(ORDER_NO, BUYER_ID);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode(), "取消订单应成功");
|
|
|
+ assertEquals("cancelled", order.getStatus(), "订单状态应更新为取消");
|
|
|
+ verify(specialPriceService).releaseQuota(DEAL_ID, 2);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("回滚流-2:退款完成 → releaseQuota 释放额度")
|
|
|
+ void 回滚流_退款完成释放额度() {
|
|
|
+ ProductOrder order = specialDealOrder();
|
|
|
+ order.setStatus("paid");
|
|
|
+ order.setRefundStatus(1);
|
|
|
+ order.setMoneyAmount(0);
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(order);
|
|
|
+
|
|
|
+ Result<String> result = service.approveRefund(ORDER_NO, 500L);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode(), "审核退款应成功");
|
|
|
+ assertEquals("refunded", order.getStatus(), "订单状态应为已退款");
|
|
|
+ verify(specialPriceService).releaseQuota(DEAL_ID, 2);
|
|
|
+ }
|
|
|
+
|
|
|
+ private ProductOrder specialDealOrder() {
|
|
|
+ ProductOrder order = new ProductOrder();
|
|
|
+ order.setOrderNo(ORDER_NO);
|
|
|
+ order.setBuyerId(BUYER_ID);
|
|
|
+ order.setProductId(PRODUCT_ID);
|
|
|
+ order.setQuantity(2);
|
|
|
+ order.setSpecialDealId(DEAL_ID);
|
|
|
+ return order;
|
|
|
+ }
|
|
|
+}
|