Răsfoiți Sursa

Merge branch 'task/S-cfc-169' into cfclub

zxyj-langgraph 2 zile în urmă
părinte
comite
fb2703e0a4

+ 256 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierOrderFulfillContractTest.java

@@ -0,0 +1,256 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.service.ProductOrderService;
+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.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+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;
+
+/**
+ * 契约级单测:服务商订单到店核销(能力键 {@code supplier.order.fulfill},任务 S-cfc-169)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-S-04 订单核销(到店)——
+ *       扫码核销→完成;边界:<b>重复核销幂等;超期订单提示不可核销</b>;</li>
+ *   <li>派生用例:{@code tests/test-cases/vendor.md} TC-S-04(重复核销幂等;超期订单提示不可核销)、
+ *       TC-S-04-E(重复扫码核销同订单;超期订单核销;核销非本服务商订单 → 403)、
+ *       TC-S-04-P(操作非本服务商订单被拒;<b>核销码校验防冒用</b>)、
+ *       TC-S-03-E(核销码被冒用/篡改 → 核销码校验失败拒);</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.8 商品与订单 ——
+ *       {@code /api/product/order/vendor}、{@code /vendor-confirm}、{@code /ship}
+ *       (已合入 origin/cfclub);SC-S-04 到店核销入口为本契约新增能力(TDD-RED 阶段)。</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.ProductOrderService#redeem(...)}
+ * —— 服务商到店核销入口。当前(2026-11-12)src/main 中 {@code pickupCode} 自提码仅在订单创建时生成
+ * ({@code generatePickupCode()}),<b>任何地方均未消费/校验</b>——无核销方法、无到期判定、无防冒用校验,
+ * 即 SC-S-04 实现未落地(真实缺口)。本测试通过反射锚定该契约方法(存在父类非关键依赖,仅以
+ * {@code ProductOrderService} 反射探测),运行时契约方法缺失即断言失败(红),暴露"实现未落地";
+ * GREEN 阶段在 ProductOrderService 落地 {@code redeem} 后本契约自动转绿。</p>
+ *
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
+ * <pre>
+ * public Result&lt;String&gt; redeem(String orderNo, String pickupCode, Long vendorId)
+ * </pre>
+ * <ul>
+ *   <li><b>正常流</b>(SC-S-04):本服务商 + 自提码匹配 + 未超期 → 核销完成,订单置「已完成」,返回成功;</li>
+ *   <li><b>异常流</b>(TC-S-03-E 防冒用):自提码不匹配 → 拒绝(提示核销码校验失败),不落库;</li>
+ *   <li><b>边界流</b>(TC-S-04):重复核销 → 幂等(第二次返回成功提示、不重复落库/不重复副作用);
+ *       超期订单(自提时段已过)→ 提示不可核销、状态不变更;</li>
+ *   <li><b>权限流</b>(TC-S-04-P):非本服务商商品订单核销 → 「无权操作」,不落库。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring 上下文 / 无 DB / 无外部服务;Mockito + 反射注入 mapper。</p>
+ *
+ * <p><b>RED 预期</b>:{@code ProductOrderService#redeem} 未实现,全部用例在反射探测处失败(红),
+ * 断言原文为「RED[supplier.order.fulfill] 契约方法 ProductOrderService#redeem 未实现」。</p>
+ */
+class SupplierOrderFulfillContractTest {
+
+    /** 契约宿主:已存在的订单服务类(redeem 方法缺失)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.ProductOrderService";
+
+    /** 到店核销自提码(订单创建时生成的字面值)。 */
+    private static final String PICKUP_CODE = "A1B2C3";
+
+    private ProductOrderService service;
+    private ProductOrderMapper orderMapper;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new ProductOrderService();
+        orderMapper = mock(ProductOrderMapper.class);
+        productMapper = mock(ProductMapper.class);
+        inject(service, "orderMapper", orderMapper);
+        inject(service, "productMapper", productMapper);
+    }
+
+    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);
+    }
+
+    // ==================== 正常流(SC-S-04:扫码核销 → 完成) ====================
+
+    @Test
+    @DisplayName("正常流:本服务商 + 自提码匹配 + 未超期 → 核销完成,订单置已完成")
+    void 正常流_自提码匹配_核销完成订单已完成() {
+        ProductOrder o = order("FO-001", "paid", 2);
+        when(orderMapper.selectOne(any(Wrapper.class))).thenReturn(o);
+        when(productMapper.selectById(10L)).thenReturn(product(7L));
+
+        Result<String> result = redeem("FO-001", PICKUP_CODE, 7L);
+
+        assertEquals(200, result.getCode(), "核销成功应返回 200");
+        assertEquals("completed", o.getStatus(), "到店核销后订单应置「已完成」");
+        verify(orderMapper).updateById(o);
+    }
+
+    // ==================== 异常流(TC-S-03-E:核销码防冒用) ====================
+
+    @Test
+    @DisplayName("异常流:自提码不匹配 → 拒绝核销(防冒用),不落库")
+    void 异常流_核销码不匹配_拒绝且不落库() {
+        ProductOrder o = order("FO-002", "paid", 2);
+        when(orderMapper.selectOne(any(Wrapper.class))).thenReturn(o);
+        when(productMapper.selectById(10L)).thenReturn(product(7L));
+
+        Result<String> result = redeem("FO-002", "WRONG-CODE", 7L);
+
+        assertEquals(500, result.getCode(), "自提码不匹配应拒绝核销");
+        assertTrue(result.getMessage().contains("核销码"),
+                "防冒用提示应含「核销码」,实际: " + result.getMessage());
+        assertEquals("paid", o.getStatus(), "拒绝核销不得变更订单状态");
+        verify(orderMapper, never()).updateById(any(ProductOrder.class));
+    }
+
+    // ==================== 边界流(TC-S-04:重复核销幂等;超期不可核销) ====================
+
+    @Test
+    @DisplayName("边界流:重复核销同订单 → 幂等(第二次成功返回,仅落库一次,无重复副作用)")
+    void 边界流_重复核销_幂等仅一次副作用() {
+        ProductOrder o = order("FO-003", "paid", 2);
+        when(orderMapper.selectOne(any(Wrapper.class))).thenReturn(o);
+        when(productMapper.selectById(10L)).thenReturn(product(7L));
+
+        Result<String> first = redeem("FO-003", PICKUP_CODE, 7L);
+        Result<String> second = redeem("FO-003", PICKUP_CODE, 7L);
+
+        assertEquals(200, first.getCode(), "首次核销应成功");
+        assertEquals(200, second.getCode(), "重复核销应幂等返回成功(不允许重复报错)");
+        assertEquals("completed", o.getStatus());
+        verify(orderMapper, times(1)).updateById(any(ProductOrder.class));
+    }
+
+    @Test
+    @DisplayName("边界流:自提时段已超期 → 提示不可核销,状态不变更")
+    void 边界流_超期订单_不可核销() {
+        ProductOrder o = order("FO-004", "paid", 2);
+        // 自提时段快照:结束时间已过期(早于今天),契约要求核销入口做超期判定
+        o.setTimeSlotSnapshot("{\"endTime\":\"2020-01-01 09:00:00\"}");
+        when(orderMapper.selectOne(any(Wrapper.class))).thenReturn(o);
+        when(productMapper.selectById(10L)).thenReturn(product(7L));
+
+        Result<String> result = redeem("FO-004", PICKUP_CODE, 7L);
+
+        assertEquals(500, result.getCode(), "超期订单应拒绝核销");
+        assertTrue(result.getMessage().contains("超期"),
+                "超期提示应含「超期」,实际: " + result.getMessage());
+        assertEquals("paid", o.getStatus(), "超期拒绝不得变更订单状态");
+        verify(orderMapper, never()).updateById(any(ProductOrder.class));
+    }
+
+    // ==================== 权限流(TC-S-04-P:非本服务商拒) ====================
+
+    @Test
+    @DisplayName("权限流:非本服务商订单核销 → 无权操作,不落库")
+    void 权限流_非本服务商_无权核销() {
+        ProductOrder o = order("FO-005", "paid", 2);
+        when(orderMapper.selectOne(any(Wrapper.class))).thenReturn(o);
+        when(productMapper.selectById(10L)).thenReturn(product(7L)); // 商品归属供应商 7
+
+        Result<String> result = redeem("FO-005", PICKUP_CODE, 8L); // 供应商 8 ≠ 7
+
+        assertEquals(500, result.getCode(), "非本服务商应拒绝核销");
+        assertTrue(result.getMessage().contains("无权"),
+                "越权提示应含「无权」,实际: " + result.getMessage());
+        assertEquals("paid", o.getStatus(), "越权拒绝不得变更订单状态");
+        verify(orderMapper, never()).updateById(any(ProductOrder.class));
+    }
+
+    // ==================== 契约方法调用(反射锚定,缺失即红) ====================
+
+    /**
+     * 按契约签名 {@code redeem(String, String, Long)} 反射调用。
+     * 方法缺失 → 断言失败(红),暴露「SC-S-04 到店核销未落地」。
+     */
+    private Result<String> redeem(String orderNo, String pickupCode, Long vendorId) {
+        Method m = findMethod("redeem", String.class, String.class, Long.class);
+        if (m == null) {
+            fail("RED[supplier.order.fulfill] 契约方法 ProductOrderService#redeem(String, String, Long) 未实现:"
+                    + "SC-S-04 到店核销(扫码核销→完成)未落地——pickupCode 自提码仅在订单创建时生成、"
+                    + "任何地方均未消费/校验(缺防冒用、缺重复核销幂等、缺超期判定、缺非本服务商越权拒绝)。"
+                    + "GREEN 阶段在 ProductOrderService 落地 redeem 后本契约自动转绿。");
+            throw new IllegalStateException("unreachable");
+        }
+        try {
+            Object r = m.invoke(service, orderNo, pickupCode, vendorId);
+            if (r instanceof Result) {
+                @SuppressWarnings("unchecked")
+                Result<String> res = (Result<String>) r;
+                return res;
+            }
+            fail("RED[supplier.order.fulfill] 契约方法 redeem 应返回 Result,实际返回: " + r);
+            throw new IllegalStateException("unreachable");
+        } catch (Exception e) {
+            fail("RED[supplier.order.fulfill] 契约方法 redeem 调用失败: " + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    private Method findMethod(String name, Class<?> p0, Class<?> p1, Class<?> p2) {
+        for (Method m : ProductOrderService.class.getDeclaredMethods()) {
+            if (m.getName().equals(name) && m.getParameterCount() == 3
+                    && m.getParameterTypes()[0].equals(p0)
+                    && m.getParameterTypes()[1].equals(p1)
+                    && m.getParameterTypes()[2].equals(p2)) {
+                return m;
+            }
+        }
+        for (Method m : ProductOrderService.class.getMethods()) {
+            if (m.getName().equals(name) && m.getParameterCount() == 3
+                    && m.getParameterTypes()[0].equals(p0)
+                    && m.getParameterTypes()[1].equals(p1)
+                    && m.getParameterTypes()[2].equals(p2)) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    // ==================== 夹具 ====================
+
+    private ProductOrder order(String orderNo, String status, int deliveryMethod) {
+        ProductOrder o = new ProductOrder();
+        o.setId(1L);
+        o.setOrderNo(orderNo);
+        o.setStatus(status);
+        o.setBuyerId(1L);
+        o.setProductId(10L);
+        o.setProductName("到店核销课程");
+        o.setProductType("physical");
+        o.setQuantity(1);
+        o.setDeliveryMethod(deliveryMethod);
+        o.setFulfillStatus("pending");
+        o.setPickupCode(PICKUP_CODE);
+        return o;
+    }
+
+    private Product product(Long vendorId) {
+        Product p = new Product();
+        p.setId(10L);
+        p.setVendorId(vendorId);
+        return p;
+    }
+}