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