Просмотр исходного кода

Merge remote-tracking branch 'origin/cfclub' into cfclub

zxyj-langgraph 2 дней назад
Родитель
Сommit
c06e4bef45

+ 314 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ShopOrderCreateSpecialDealContractTest.java

@@ -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 累计 &lt; 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;
+    }
+}