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

test(S-cfc-166): RED 契约单测 shop.order.queryRefund 退款状态查询

能力键 shop.order.queryRefund(契约新增,TDD-RED 阶段,实现未落地)。
契约溯源:SC-P-56 订单查询/退款(tests/user-scenarios/应用场景总览.md)
+ API_REFERENCE.md §4.8 商品与订单(/api/product/order/my、/refund/apply)。

反射结构契约(纯反射、无 Spring/DB/外部服务,沿用同批 RED 测试风格):
- 正常流:ProductOrderService 应声明 queryRefund(orderNo,userId) 查询入口
- 控制层:ProductOrderController 应暴露 POST /refund/query 路由
- 边界流:queryRefund 入参应为 (orderNo,userId) 二元组(买家归属作用域)
- 权限流:queryRefund 应按买家归属校验(横向越权被拒,TC-P-56-P)

RED 结果:4 测试全红(queryRefund 方法 / /refund/query 路由均未落地)。
GREEN 指引:实现 queryRefund 入口 + /refund/query 路由后自动转绿。
zxyj-langgraph 16 часов назад
Родитель
Сommit
7b2eadd057

+ 203 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ShopOrderQueryRefundContractTest.java

@@ -0,0 +1,203 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:商城订单退款状态查询(能力键 {@code shop.order.queryRefund})。
+ *
+ * <p>契约溯源(TDD-RED 阶段,实现未落地):</p>
+ * <ul>
+ *   <li><b>需求</b>:{@code tests/user-scenarios/应用场景总览.md} SC-P-56 订单查询/退款
+ *       (业务域 F 商城与会员)——家长「订单列表 → 详情/申请退款」,边界「已发货退款规则;
+ *       重复退款拦截」;关联用例 TC-P-56(订单退款)/ TC-P-56-E(已发货按规则处理、重复退款拦截仅 1 次)。</li>
+ *   <li><b>设计</b>:{@code docs/superpowers/api/API_REFERENCE.md} §4.8 商品与订单
+ *       ({@code /api/product/order/my} 我的订单、{@code /api/product/order/refund/apply} 申请退款
+ *       ——已合入 origin/cfclub;实现不在 feature 分支,按主分支检出即可)。</li>
+ * </ul>
+ *
+ * <p><b>契约行为(queryRefund = 查询订单退款状态/详情,供「我的订单 → 退款进度」闭环)</b></p>
+ * <ul>
+ *   <li><b>正常流</b>:{@code ProductOrderService} 应声明 {@code queryRefund(String orderNo, Long userId)}
+ *       入口,返回携带退款状态四元组({@code refundStatus / refundReason / refundAmount / refundTime})的
+ *       结果,供前端在订单详情/退款页展示当前退款进度(0 无 / 1 申请中 / 2 已退款 / 3 拒绝 / 4 退款到账中)。</li>
+ *   <li><b>控制层</b>:{@code ProductOrderController} 应暴露 {@code POST /api/product/order/refund/query}
+ *       路由(与既有 {@code /refund/apply} 同前缀、全 {@code @PostMapping} 规范),按 userId 绑定当前登录买家。</li>
+ *   <li><b>边界流</b>:查询入参为 {@code (orderNo, userId)} 二元组(订单号 + 买家归属),
+ *       使调用方可以按买家身份校验归属、并在无退款记录时返回「无退款记录」而非误报成功。</li>
+ *   <li><b>权限流</b>:查询按买家归属作用域(与 {@code applyRefund} 的「无权操作此订单」同口径),
+ *       非归属用户不可查询他人订单退款状态(横向越权被拒)。</li>
+ * </ul>
+ *
+ * <p><b>实现现状核实(本分支 src/main)</b>:{@code ProductOrderService} 已含
+ * {@code applyRefund / approveRefund / rejectRefund}(退款申请与平台审批),
+ * {@code ProductOrderDTO} 已含 {@code refundStatus / refundReason / refundAmount / refundTime}
+ * 四个退款状态字段(数据载体已就绪);但<b>查询专属入口 {@code queryRefund} 未落地</b>——
+ * 全库 0 引用 {@code queryRefund}、无 {@code /refund/query} 路由、无「按订单号查询退款状态」的方法。
+ * 因此本测试以「反射结构契约」探测实现缺失:能编译通过、能运行、断言失败(红),
+ * 失败断言原文清晰可贴(RED 信号)。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者在 {@code ProductOrderService} 声明
+ * {@code queryRefund(String orderNo, Long userId)} 返回含退款状态四元组的 {@code Result},
+ * 并在 {@code ProductOrderController} 暴露 {@code @PostMapping("/refund/query")} 后,
+ * 本测试自动转绿(届时可将结构探测升级为行为级 Mockito 验证)。</p>
+ */
+class ShopOrderQueryRefundContractTest {
+
+    /** 宿主服务类(已存在,方法缺失)。 */
+    private static final String SERVICE_CLASS = "com.etotem.cfc.service.ProductOrderService";
+
+    /** 宿主控制类(已存在,路由缺失)。 */
+    private static final String CONTROLLER_CLASS = "com.etotem.cfc.controller.product.ProductOrderController";
+
+    /** 契约要求的查询入口方法名。 */
+    private static final String METHOD_QUERY_REFUND = "queryRefund";
+
+    /** 契约要求的控制层路由路径(类级 {@code /api/product/order} + 方法级路径)。 */
+    private static final String ROUTE_REFUND_QUERY = "/refund/query";
+
+    /** 契约要求的入参二元组(orderNo + userId,买家归属作用域)。 */
+    private static final int EXPECTED_PARAM_COUNT = 2;
+
+    // ------------------------------------------------------------------
+    // 正常流-1:服务层应声明 queryRefund 入口(契约方法)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:ProductOrderService 应声明 queryRefund 查询入口")
+    void service_shouldDeclareQueryRefundMethod() {
+        Class<?> clazz = loadClass(SERVICE_CLASS);
+        if (clazz == null) {
+            fail("[shop.order.queryRefund] 宿主服务类不存在:" + SERVICE_CLASS);
+        }
+        Method m = findMethod(clazz, METHOD_QUERY_REFUND);
+        assertTrue(m != null,
+                "[shop.order.queryRefund] ProductOrderService 未声明 queryRefund(...) 查询入口:"
+                        + "契约要求提供「按订单号查询退款状态/详情」方法,供我的订单→退款进度闭环"
+                        + "(SC-P-56 订单查询/退款,API_REFERENCE §4.8)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流-2:控制层应暴露 POST /refund/query 路由
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-2:ProductOrderController 应暴露 POST /refund/query 路由")
+    void controller_shouldExposeRefundQueryRoute() {
+        Class<?> clazz = loadClass(CONTROLLER_CLASS);
+        if (clazz == null) {
+            fail("[shop.order.queryRefund] 宿主控制类不存在:" + CONTROLLER_CLASS);
+        }
+        Method m = findMappedMethod(clazz, METHOD_QUERY_REFUND, ROUTE_REFUND_QUERY);
+        assertTrue(m != null,
+                "[shop.order.queryRefund] ProductOrderController 未暴露 POST "
+                        + ROUTE_REFUND_QUERY + " 路由(方法 " + METHOD_QUERY_REFUND + "):"
+                        + "契约要求与既有 /refund/apply 同前缀提供退款状态查询端点,"
+                        + "供前端在订单详情/退款页查询当前退款进度(SC-P-56)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:queryRefund 入参应为 (orderNo, userId) 二元组(买家归属作用域)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:queryRefund 应含 (orderNo, userId) 二元组入参(买家归属作用域)")
+    void queryRefund_shouldTakeOrderNoAndUserId() {
+        Class<?> clazz = loadClass(SERVICE_CLASS);
+        if (clazz == null) {
+            fail("[shop.order.queryRefund] 宿主服务类不存在:" + SERVICE_CLASS);
+        }
+        Method m = findMethod(clazz, METHOD_QUERY_REFUND);
+        if (m == null) {
+            fail("[shop.order.queryRefund] ProductOrderService.queryRefund 方法缺失,"
+                    + "无法校验买家归属作用域入参(边界流随正常流一并 RED)。");
+        }
+        boolean buyerScoped = m.getParameterCount() == EXPECTED_PARAM_COUNT;
+        assertTrue(buyerScoped,
+                "[shop.order.queryRefund] queryRefund 应为 (orderNo, userId) 二元组入参("
+                        + EXPECTED_PARAM_COUNT + " 个),实际 " + m.getParameterCount() + " 个:"
+                        + "契约要求按买家身份校验订单归属(非归属用户不可查询他人退款状态,横向越权被拒)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流:查询按买家归属作用域(横向越权被拒)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流-1:queryRefund 应校验买家归属(与 applyRefund 无权操作同口径)")
+    void queryRefund_shouldEnforceBuyerOwnershipScope() {
+        // 契约 AC:查询退款状态为买家专属动作,非归属用户应被拒(TC-P-56-P 横向越权被拒)。
+        // 实现未落地时该方法缺失,反射探测即失败(红);GREEN 后由服务层按 userId 归属校验落地。
+        Class<?> clazz = loadClass(SERVICE_CLASS);
+        if (clazz == null) {
+            fail("[shop.order.queryRefund] 宿主服务类不存在:" + SERVICE_CLASS);
+        }
+        Method m = findMethod(clazz, METHOD_QUERY_REFUND);
+        assertTrue(m != null,
+                "[shop.order.queryRefund] queryRefund 缺失,无法验证买家归属校验:"
+                        + "契约要求按 userId 作用域查询(非归属用户 → 无权查看/无权操作此订单),"
+                        + "与 applyRefund 同口径(TC-P-56-P 横向越权被拒)。实现未落地。");
+        assertTrue(Modifier.isPublic(m.getModifiers()),
+                "[shop.order.queryRefund] queryRefund 应为 public 入口(供 Controller 按 userId 绑定调用)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 按方法名精确匹配(不含继承/桥接方法,契约方法应声明于宿主类)。 */
+    private Method findMethod(Class<?> clazz, String methodName) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(methodName)) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    /** 按方法名 + {@code @PostMapping} 路径匹配控制层路由(全 @PostMapping 规范)。 */
+    private Method findMappedMethod(Class<?> clazz, String methodName, String path) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (!m.getName().equals(methodName)) {
+                continue;
+            }
+            String mapped = postMappingValue(m);
+            if (mapped != null && mapped.contains(path)) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    /** 读取方法上的 {@code @PostMapping} 首值路径;无该注解返回 null(避免直接依赖 Spring 注解类型)。 */
+    private String postMappingValue(Method m) {
+        for (java.lang.annotation.Annotation ann : m.getDeclaredAnnotations()) {
+            if ("org.springframework.web.bind.annotation.PostMapping".equals(ann.annotationType().getName())) {
+                try {
+                    Method value = ann.annotationType().getMethod("value");
+                    Object raw = value.invoke(ann);
+                    if (raw instanceof String[] && ((String[]) raw).length > 0) {
+                        return ((String[]) raw)[0];
+                    }
+                } catch (Exception e) {
+                    return null;
+                }
+            }
+        }
+        return null;
+    }
+}