|
|
@@ -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<String> 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;
|
|
|
+ }
|
|
|
+}
|