Browse Source

test(S-cfc-308): 新增 admin.familyRelation.manage 契约级单测(SC-A-04 关系类型 CRUD,TDD-RED,8 断言预期红)

Agnes Agent 12 hours ago
parent
commit
5351e93efd

+ 381 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminFamilyRelationManageContractTest.java

@@ -0,0 +1,381 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.net.JarURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.JarFile;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 契约级单测(TDD-RED,S-cfc-308):能力键 {@code admin.familyRelation.manage}
+ * (平台运营·关系类型 CRUD 管理,单元 admin)。
+ *
+ * <p><b>需求溯源</b>:{@code tests/user-scenarios/应用场景总览.md} <b>SC-A-04 关系类型 CRUD</b>——
+ * 目标:管理家庭关系;流程:关系类型增删改查 → 生效;边界:被引用时删需确认;模块:{@code RelationshipTypes.vue}。</p>
+ *
+ * <p><b>用例绑定(TC 台账 → 资产回溯锚点)</b>:{@code tests/test-cases/admin.md}
+ * <ul>
+ *   <li><b>TC-A-10</b>(功能):新增/编辑/停用关系类型 → 操作成功;家长端关系选择器同步生效;</li>
+ *   <li><b>TC-A-10-E</b>(异常):新增重名拒绝;停用被引用类型提示先解绑;名称含注入 400/转义;</li>
+ *   <li><b>TC-A-10-P</b>(权限):非 admin 角色访问管理端接口统一 401/403,不泄露后台数据。</li>
+ * </ul></p>
+ *
+ * <p><b>设计溯源</b>:{@code docs/superpowers/api/API_REFERENCE.md}
+ * <ul>
+ *   <li>§4.2 / §4.20:家庭域 {@code /api/family/relationship/*}({@code RelationshipQualityController})
+ *       仅提供<b>只读评分查询</b>(scores/calculate/health-alerts/milestones/pairwise),无 CRUD 载体;</li>
+ *   <li>§4.60.18 契约缺口:SC-A-04 登记「无端点载体,需产品侧确认」——{@code relationship_types} 表已废弃 DROP,
+ *       关系由 {@code family_relationships}(generation 辈分模型 + {@code FamilyRelationshipService})承载;</li>
+ *   <li><b>本键 design 需求(契约目标)</b>:新增 admin/family-relation 端点
+ *       GET/POST/PUT/DELETE {@code /api/admin/family-relation/types}(关系类型增删改查 + 字典管理),
+ *       模块 {@code AdminFamilyRelationController}(待建)。</li>
+ * </ul></p>
+ *
+ * <p><b>实现现状(src/main 核实)</b>:{@code /api/admin/family-relation} 路径全库 0 引用,
+ * {@code AdminFamilyRelationController} 不存在,任何关系类型字典 CRUD 操作均未落地 → 本契约应全红。
+ * (若实测意外全绿,说明实现已存在,按编排侧流程转测试登记,不私改实现。)</p>
+ *
+ * <p><b>包路径不定约定</b>:O-2 方案 A 实现待定,<b>不得假定宿主包路径</b>。故本测试不硬编码
+ * {@code AdminFamilyRelationController} 的 FQCN,改为<b>类路径扫描</b> {@code com.etotem.cfc.controller}
+ * 下映射 {@code /api/admin/family-relation} 的控制器(含子包递归)——GREEN 实现放任意子包均可被探测。
+ * 若类路径扫描失败也会显式红(扫描器自检用例),避免「扫描器坏 → 永久红/假绿」。</p>
+ *
+ * <p><b>动词约定</b>:项目接口统一 {@code @PostMapping},而本键 design 明确要求 GET/POST/PUT/DELETE 动词端点。
+ * 测试按<b>语义操作</b>(列表/新增/编辑/停用)探测,不强制 HTTP 动词——GREEN 无论按 REST 动词还是
+ * POST 子路径({@code /types/list} 等)落地均可转绿;设计动词要求记录于下方。</p>
+ *
+ * <p><b>契约方法签名(实现方据此落地,GREEN 后本测试自动转绿)</b></p>
+ * <ul>
+ *   <li>控制器类级映射 {@code /api/admin/family-relation}(包路径任意,扫描发现)</li>
+ *   <li>列表:{@code GET|POST <prefix>/types} 或 {@code /types/list} —— 关系类型字典列表(TC-A-10 查询)</li>
+ *   <li>新增:{@code POST <prefix>/types} 或 {@code /types/save} —— 新增(重名校验,TC-A-10-E)</li>
+ *   <li>编辑:{@code PUT|POST <prefix>/types} 或 {@code /types/update} —— 编辑(TC-A-10)</li>
+ *   <li>停用/删除:{@code DELETE|POST <prefix>/types} 或 {@code /types/disable|delete} —— 停用;
+ *       被 {@code family_relationships} 引用时删需确认/提示先解绑(SC-A-04 边界 + TC-A-10-E)</li>
+ * </ul>
+ *
+ * <p><b>测试风格</b>:与同批 RED 契约测试一致——纯反射 + 类路径扫描,无 Spring 上下文 / 无 DB / 无外部服务;
+ * 宿主缺失即全部用例红(RED),断言原文清晰可贴;GREEN 实现宿主后自动转绿。行为级细节(重名拒绝、
+ * 被引用确认、注入防护)由 GREEN 阶段行为级测试补充验证,本类负责「载体 + 操作是否存在」的契约锚定。</p>
+ */
+class AdminFamilyRelationManageContractTest {
+
+    /** 契约端点前缀(design 明确):关系类型字典管理 */ 
+    private static final String API_PREFIX = "/api/admin/family-relation";
+
+    /** 类型字典资源段(GET/POST/PUT/DELETE /api/admin/family-relation/types) */
+    private static final String TYPES = "types";
+
+    /** 类路径扫描根(项目控制器统一目录,含子包) */
+    private static final String CONTROLLER_BASE_PKG = "com.etotem.cfc.controller";
+
+    /** 扫描自检锚点:既有的只读评分控制器(证明扫描器有效,非扫描故障导致红) */
+    private static final String SANITY_EXISTING =
+            "com.etotem.cfc.controller.family.RelationshipQualityController";
+
+    // ==================== 正常流:/api/admin/family-relation 管理控制器载体 ====================
+
+    @Test
+    @DisplayName("正常流-1:/api/admin/family-relation 管理控制器应存在(包路径不定,类路径扫描发现)")
+    void normalFlow_controllerCarrierExists() {
+        // 扫描器自检:既能发现既有控制器,又未命中 /api/admin/family-relation
+        assertTrue(scanControllerClasses().contains(SANITY_EXISTING),
+                "RED[admin.familyRelation.manage] 类路径扫描未发现既有控制器 " + SANITY_EXISTING
+                        + ":扫描器故障会导致后续探测失真(本应为 GREEN 自检锚点)。");
+        assertFalse(discoverAdminFamilyRelationControllers().isEmpty(),
+                "RED[admin.familyRelation.manage] 未发现映射 " + API_PREFIX + " 的管理控制器"
+                        + "(AdminFamilyRelationController 待建,包路径不定 → 类路径扫描):"
+                        + "契约要求 GET/POST/PUT/DELETE " + API_PREFIX + "/types 承载关系类型字典"
+                        + "增删改查 + 字典管理(SC-A-04 + TC-A-10)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:应提供关系类型列表/查询接口(types · 字典列表,TC-A-10 查询)")
+    void normalFlow_typesListOperation() {
+        assertTrue(servesTypeOperation(TypeOp.LIST),
+                "RED[admin.familyRelation.manage] 控制器缺少关系类型列表/查询操作"
+                        + "(GET|POST " + API_PREFIX + "/types 或 /types/list):"
+                        + "TC-A-10 关系类型管理页需展示字典列表。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:应提供关系类型新增接口(types · 新增,TC-A-10)")
+    void normalFlow_typesCreateOperation() {
+        assertTrue(servesTypeOperation(TypeOp.CREATE),
+                "RED[admin.familyRelation.manage] 控制器缺少关系类型新增操作"
+                        + "(POST " + API_PREFIX + "/types 或 /types/save):"
+                        + "TC-A-10「新增关系类型 → 操作成功、家长端关系选择器同步生效」。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:应提供关系类型编辑接口(types · 编辑,TC-A-10)")
+    void normalFlow_typesUpdateOperation() {
+        assertTrue(servesTypeOperation(TypeOp.UPDATE),
+                "RED[admin.familyRelation.manage] 控制器缺少关系类型编辑操作"
+                        + "(PUT|POST " + API_PREFIX + "/types 或 /types/update):"
+                        + "TC-A-10「编辑关系类型 → 操作成功、家长端关系选择器同步生效」。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:应提供关系类型停用/删除接口(types · 停用,TC-A-10 + SC-A-04 删)")
+    void normalFlow_typesDisableOperation() {
+        assertTrue(servesTypeOperation(TypeOp.DISABLE),
+                "RED[admin.familyRelation.manage] 控制器缺少关系类型停用/删除操作"
+                        + "(DELETE|POST " + API_PREFIX + "/types 或 /types/disable|delete):"
+                        + "TC-A-10「停用关系类型」+ SC-A-04「关系类型增删改查 → 生效」。实现未落地。");
+    }
+
+    // ==================== 边界流:TC-A-10-E(异常) ====================
+
+    @Test
+    @DisplayName("边界流-1:新增重名关系类型应被拒绝(TC-A-10-E · 名称唯一性契约)")
+    void boundary_duplicateNameRejected() {
+        // 契约:关系类型字典名称唯一,「新增重名类型 → 拒绝」(TC-A-10-E 步骤1 预期)。
+        // 载体(types Create 操作)未落地 → 该行为必然缺位,契约红;
+        // GREEN 转绿条件 = Create 操作存在(名称唯一校验为 GREEN 阶段行为实现点)。
+        assertTrue(servesTypeOperation(TypeOp.CREATE),
+                "RED[admin.familyRelation.manage] 新增接口未落地,'新增重名关系类型应拒绝'行为缺位"
+                        + "(TC-A-10-E:重名拒绝;GREEN 阶段 Create 操作须承载名称唯一校验)。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:停用被引用关系类型需确认/提示先解绑(TC-A-10-E + SC-A-04 边界)")
+    void boundary_referencedTypeDisableNeedsConfirm() {
+        // 契约:关系类型被 family_relationships 引用时,停用/删除需确认或提示先解绑
+        // (SC-A-04 边界「被引用时删需确认」;TC-A-10-E 步骤2「停用被引用类型提示先解绑」)。
+        // 载体(types Disable 操作)未落地 → 该守卫行为必然缺位,契约红;
+        // GREEN 转绿条件 = Disable 操作存在(引用检查为 GREEN 阶段行为实现点)。
+        assertTrue(servesTypeOperation(TypeOp.DISABLE),
+                "RED[admin.familyRelation.manage] 停用/删除接口未落地,'被引用类型停用需先解绑/确认'"
+                        + "守卫行为缺位(SC-A-04 边界 + TC-A-10-E 步骤2;"
+                        + "GREEN 阶段 Disable 操作须做 family_relationships 引用检查)。");
+    }
+
+    // ==================== 权限流:TC-A-10-P(管理端 401/403 门禁) ====================
+
+    @Test
+    @DisplayName("权限流:接口应挂 /api/admin 前缀(JWT 拦截 + 管理员角色门禁,非 admin 统一 401/403)")
+    void permissionFlow_adminApiPrefixGate() {
+        // TC-A-10-P:非 admin 角色访问管理端接口统一 401/403,不泄露后台数据。
+        // 机理由 JwtInterceptor 拦截 /api/admin/** + 控制器内 role=admin 校验承载;
+        // 宿主未落地 → 门禁随之缺位,契约红。
+        List<Class<?>> ctls = discoverAdminFamilyRelationControllers();
+        assertFalse(ctls.isEmpty(),
+                "RED[admin.familyRelation.manage] " + API_PREFIX + " 管理控制器未实现:"
+                        + "TC-A-10-P 要求非 admin 访问管理端接口统一 401/403,"
+                        + "JwtInterceptor 拦截 /api/admin/** + role=admin 门禁随之缺位。");
+        for (Class<?> c : ctls) {
+            boolean underAdmin = false;
+            for (String p : mappingValues(c.getAnnotations())) {
+                if (p.startsWith("/api/admin")) {
+                    underAdmin = true;
+                    break;
+                }
+            }
+            assertTrue(underAdmin,
+                    "RED[admin.familyRelation.manage] 控制器 " + c.getName()
+                            + " 类级映射应挂 /api/admin 前缀(JWT 拦截 + 管理员角色门禁,TC-A-10-P)。");
+        }
+    }
+
+    // ==================== 反射辅助(类路径扫描 + 端点语义探测,不假定包路径) ====================
+
+    private enum TypeOp { LIST, CREATE, UPDATE, DISABLE }
+
+    /** 新增语义关键字 */
+    private static final String[] CREATE_KW = {"save", "add", "create", "insert"};
+    /** 编辑语义关键字 */
+    private static final String[] UPDATE_KW = {"update", "edit", "modify", "rename"};
+    /** 停用/删除语义关键字(SC-A-04 删 + TC-A-10 停用) */
+    private static final String[] DISABLE_KW = {"disable", "delete", "remove", "offline", "deactivate"};
+    /** 列表/查询语义关键字 */
+    private static final String[] LIST_KW = {"list", "query", "page", "select", "find", "all"};
+
+    /** 扫描 com.etotem.cfc.controller(含子包递归),筛出类级映射 /api/admin/family-relation 的控制器。 */
+    private List<Class<?>> discoverAdminFamilyRelationControllers() {
+        List<Class<?>> found = new ArrayList<Class<?>>();
+        for (String fqcn : scanControllerClasses()) {
+            Class<?> c = loadClass(fqcn);
+            if (c == null) {
+                continue;
+            }
+            for (String p : mappingValues(c.getAnnotations())) {
+                if (p.equals(API_PREFIX) || p.startsWith(API_PREFIX + "/")) {
+                    found.add(c);
+                    break;
+                }
+            }
+        }
+        return found;
+    }
+
+    /** 控制器包递归扫描(file: 目录递归 + jar: 直接子类,ClassLoader 资源形态) */
+    private List<String> scanControllerClasses() {
+        List<String> names = new ArrayList<String>();
+        collectPackage(CONTROLLER_BASE_PKG, names);
+        Set<String> unique = new HashSet<String>(names);
+        return new ArrayList<String>(unique);
+    }
+
+    private void collectPackage(String pkg, List<String> out) {
+        try {
+            String path = pkg.replace('.', '/');
+            ClassLoader cl = Thread.currentThread().getContextClassLoader();
+            Enumeration<URL> resources = cl.getResources(path);
+            while (resources.hasMoreElements()) {
+                URL url = resources.nextElement();
+                String protocol = url.getProtocol();
+                if ("file".equals(protocol)) {
+                    File dir = new File(url.toURI());
+                    File[] files = dir.listFiles();
+                    if (files == null) {
+                        continue;
+                    }
+                    for (File f : files) {
+                        if (f.isDirectory()) {
+                            collectPackage(pkg + "." + f.getName(), out);
+                        } else if (f.isFile() && f.getName().endsWith(".class")
+                                && !f.getName().contains("$")) {
+                            out.add(pkg + "." + f.getName().substring(0, f.getName().length() - 6));
+                        }
+                    }
+                } else if ("jar".equals(protocol)) {
+                    // 应用类在 target/classes(file:)下,jar 分支仅兜底取当前包直接子类
+                    JarURLConnection conn = (JarURLConnection) url.openConnection();
+                    try (JarFile jar = conn.getJarFile()) {
+                        String prefix = path + "/";
+                        Enumeration<java.util.jar.JarEntry> entries = jar.entries();
+                        while (entries.hasMoreElements()) {
+                            java.util.jar.JarEntry e = entries.nextElement();
+                            String n = e.getName();
+                            if (n.startsWith(prefix) && n.endsWith(".class")
+                                    && !n.substring(prefix.length()).contains("$")
+                                    && n.indexOf('/', prefix.length()) < 0) {
+                                out.add(n.substring(0, n.length() - 6).replace('/', '.'));
+                            }
+                        }
+                    }
+                }
+            }
+        } catch (Exception ignored) {
+            // 包扫描失败 → 按缺失处理(外层断言会红,扫描器自检用例可定位)
+        }
+    }
+
+    /** 该方法是否为关系类型(types)管理端点:类级或方法级映射含 types 段。 */
+    private boolean targetsTypes(Class<?> ctrl, Method m) {
+        if (mappingValues(m.getAnnotations()).isEmpty()) {
+            return false; // 无映射注解的方法不是端点
+        }
+        for (String p : mappingValues(ctrl.getAnnotations())) {
+            if (p.contains(TYPES)) {
+                return true;
+            }
+        }
+        for (String p : mappingValues(m.getAnnotations())) {
+            if (p.contains(TYPES)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 关系类型操作语义分类:类型管理端点 → LIST/CREATE/UPDATE/DISABLE;非类型端点 → null。 */
+    private TypeOp classifyTypeOp(Class<?> ctrl, Method m) {
+        if (!targetsTypes(ctrl, m)) {
+            return null;
+        }
+        StringBuilder sb = new StringBuilder(m.getName());
+        sb.append(' ');
+        for (Annotation a : m.getAnnotations()) {
+            sb.append(a.annotationType().getSimpleName()).append(' ');
+        }
+        for (String p : mappingValues(m.getAnnotations())) {
+            sb.append(p).append(' ');
+        }
+        for (String p : mappingValues(ctrl.getAnnotations())) {
+            sb.append(p).append(' ');
+        }
+        String sig = sb.toString().toLowerCase();
+
+        if (containsAny(sig, CREATE_KW)) {
+            return TypeOp.CREATE;
+        }
+        if (containsAny(sig, UPDATE_KW)) {
+            return TypeOp.UPDATE;
+        }
+        if (containsAny(sig, DISABLE_KW)) {
+            return TypeOp.DISABLE;
+        }
+        if (containsAny(sig, LIST_KW) || sig.trim().startsWith("get")) {
+            return TypeOp.LIST;
+        }
+        return null;
+    }
+
+    /** 已发现的控制器中是否存在某类关系类型操作。 */
+    private boolean servesTypeOperation(TypeOp op) {
+        for (Class<?> c : discoverAdminFamilyRelationControllers()) {
+            for (Method m : c.getDeclaredMethods()) {
+                if (classifyTypeOp(c, m) == op) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private boolean containsAny(String s, String[] keywords) {
+        for (String kw : keywords) {
+            if (s.contains(kw)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 读取 Spring 映射注解(@RequestMapping/@GetMapping/@PostMapping/@PutMapping/@DeleteMapping)的 value() 路径集合。 */
+    private List<String> mappingValues(Annotation[] annotations) {
+        List<String> paths = new ArrayList<String>();
+        for (Annotation a : annotations) {
+            String tn = a.annotationType().getName();
+            if (tn.equals("org.springframework.web.bind.annotation.RequestMapping")
+                    || tn.equals("org.springframework.web.bind.annotation.PostMapping")
+                    || tn.equals("org.springframework.web.bind.annotation.GetMapping")
+                    || tn.equals("org.springframework.web.bind.annotation.PutMapping")
+                    || tn.equals("org.springframework.web.bind.annotation.DeleteMapping")) {
+                try {
+                    Object v = a.annotationType().getMethod("value").invoke(a);
+                    if (v instanceof String[]) {
+                        for (String s : (String[]) v) {
+                            paths.add(s);
+                        }
+                    }
+                } catch (Exception ignored) {
+                    // 非路径型映射注解,忽略
+                }
+            }
+        }
+        return paths;
+    }
+}