Преглед на файлове

test(RED): S-cfc-193 teacher.client.export 客户档案数据导出(合规) 契约级单测 6例全红

能力键 teacher.client.export(SC-T-29 客户档案数据导出·合规)契约 RED 测试。
溯源:sc-key-map SC-T-29;EVD-10 契约(77026df42);API_REFERENCE §4.57;
用例台账 GAP-053(TC-T-29 正常流 / TC-T-29-E 异常流 / TC-T-29-P 权限流)。
设计锚点:FamilyExportController(派发单指定设计宿主,当前 src/main 未落地)。
实测确认 origin/cfclub @ e83c92b84:FamilyExportController 不存在;
AdminOrgMemberController /api/admin/org/member/* 仅 list/add/remove/change-type 无 export;
UserController /data-export 仅为登录用户本人个保法可携带权导出(缺口 G-3)。

契约断言(反射结构,纯 JUnit 无 Spring/DB/外部服务):
- 正常流:宿主类存在 + 导出入口 exportClientArchive(范围入参>=1)
- 边界流:导出范围显式入参(Map/数值ID,越授权可拒/空范围拒绝)
- 异常流:统一 Result 包装(失败 Result.error 不裸抛;children 缺列按 family_members 聚合现状处理)
- 权限流:注入 GuideFamilyAccessGuard 授权守卫(isBound 授权源)或方法携带角色上下文
- 合规流:敏感字段强制脱敏契约(对齐 SC-S-11 DataMaskUtil 口径)

RED 验证:Tests run: 6, Failures: 6(宿主缺失断言,非编译错误)。
GREEN 阶段按契约实现 FamilyExportController 后转绿。
Sisyphus Agent преди 22 часа
родител
ревизия
da78baefa7
променени са 1 файла, в които са добавени 196 реда и са изтрити 0 реда
  1. 196 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherExportContractTest.java

+ 196 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherExportContractTest.java

@@ -0,0 +1,196 @@
+package com.etotem.cfc.unit;
+
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:客户档案数据导出(合规)(能力键 {@code teacher.client.export},SC-T-29,TDD-RED 阶段)。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-29「客户档案数据导出(合规)」——
+ *       规划师(teacher)导出其名下客户档案数据的机构视角批量导出;sc-key-map 台账
+ *       {@code docs/tdd/orchestration/sc-key-map.md} 登记 SC-T-29 → 能力键 {@code teacher.client.export}(RED/未跑通);
+ *       用例台账 GAP-053(TC-T-29 正常流 / TC-T-29-E 异常流 / TC-T-29-P 权限流)。</li>
+ *   <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md}(EVD-10 契约 77026df42)
+ *       §1 实测回填 + §4 G-3——
+ *       「管理端/规划师侧客户档案导出缺失:AdminOrgMemberController /api/admin/org/member/* 仅 list/add/remove/change-type,
+ *       无 export;现有 UserController POST /api/user/data-export 是登录用户本人的个保法数据可携带权导出
+ *       ({@code AccountCleanupService.exportUserData}),不覆盖「规划师导出其名下客户档案」的管理员场景」。
+ *       建议处置:后端新增导出入口(可复用 accountCleanupService 或导出 CSV)。</li>
+ *   <li>宿主契约(本 RED 测试定稿,派发单设计锚点):{@code com.etotem.cfc.controller.FamilyExportController}
+ *       ——「客户档案数据导出」设计目标控制器。origin/cfclub @ e83c92b84 实测 <b>不存在</b>(实现未落地),
+ *       测试以反射结构契约探测缺失,失败断言原文红色清晰可贴。</li>
+ * </ul>
+ *
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
+ * <ul>
+ *   <li>{@code Result<Map> exportClientArchive(Map<String,Object> params)}(或
+ *       {@code Result<Map> exportClientArchive(Long scopeMemberId)})——客户档案导出入口:
+ *       导出范围<u>显式入参</u>(仅限绑定客户;越授权可拒 / 空范围返回错误),
+ *       统一返回 {@code Result} 包装(失败不裸抛异常)。参数按仓库 Controller 惯例可追加
+ *       {@code userId/role} 等角色上下文参数。</li>
+ * </ul>
+ *
+ * <p><b>三类流覆盖(GAP-053)</b></p>
+ * <ul>
+ *   <li><b>正常流(TC-T-29)</b>:设计宿主控制器存在,提供客户档案导出入口(导出范围入参 ≥1);</li>
+ *   <li><b>边界/异常流(TC-T-29-E)</b>:导出范围显式入参(Map 或数值 ID 载体,越授权可拒/空范围错误返回),
+ *       返回统一 {@code Result} 包装(失败 {@code Result.error},不裸抛);注意 {@code children} 表生产
+ *       缺 {@code phone/id_card} 两列的历史约束(根 AGENTS.md「children 表标注冲突」),导出全字段 SELECT
+ *       需按现状经 {@code family_members} 聚合处理,不得依赖 {@code children} 全字段快照;</li>
+ *   <li><b>权限流(TC-T-29-P)</b>:teacher 角色守卫——宿主注入全项目唯一授权判定点
+ *       {@code GuideFamilyAccessGuard}({@code guide_families.status='binding'} isBound 授权源,SC-T-13 同源)
+ *       或导出方法携带角色上下文参数(Controller 惯例 {@code params+userId+role}),越授权客户不可导出。</li>
+ * </ul>
+ *
+ * <p><b>合规口径</b>:客户档案导出为合规能力(个保法),导出 {@code phone/realName/idCard} 等敏感字段须
+ * 强制脱敏/最小化(复用 {@code DataMaskUtil},对齐 SC-S-11 {@code supplier.data.exportCompliance} 契约口径)。</p>
+ *
+ * <p><b>RED 预期</b>:宿主 {@code FamilyExportController} 未实现,全部用例在反射探测处失败(红),
+ * 断言原文为「契约宿主 FamilyExportController 未实现」;GREEN 阶段按上述契约落地后转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯 JUnit + 反射结构契约,无 Spring / 无 DB / 无外部服务(同批
+ * {@code TeamProfitHookContractTest} / {@code TeamStarMultiplierContractTest} 风格)。</p>
+ */
+class TeacherExportContractTest {
+
+    /** 设计宿主类(SC-T-29 客户档案导出控制器,派发单设计锚点;当前实现未落地)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.controller.FamilyExportController";
+
+    /** 导出入口方法名(契约定稿,GREEN 实现方按此落地)。 */
+    private static final String ENTRY_METHOD = "exportClientArchive";
+
+    /** SC-T-13 同源授权守卫(全项目唯一授权判定点,现有类):绑定关系就是导出授权源。 */
+    private static final String AUTHORITY_GUARD = "com.etotem.cfc.service.GuideFamilyAccessGuard";
+
+    // ==================== 正常流(TC-T-29):宿主控制器 + 导出入口 ====================
+
+    @Test
+    @DisplayName("正常流-1:FamilyExportController 客户档案导出控制器应存在(设计宿主锚点)")
+    void controller_classExists_teacherClientExport() {
+        Class<?> clazz = hostClass();
+        assertNotNull(clazz,
+                "[teacher.client.export] FamilyExportController 类不存在:SC-T-29 客户档案数据导出(合规)"
+                        + "要求规划师侧客户档案导出入口(契约缺口 G-3,AdminOrgMemberController 无 export)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:导出入口方法 exportClientArchive 应存在(导出范围入参 ≥1)")
+    void controller_hasExportEntryMethod() {
+        Class<?> clazz = hostClass();
+        Method entry = findMethod(clazz, ENTRY_METHOD, 1);
+        assertNotNull(entry,
+                "[teacher.client.export] FamilyExportController 应提供 " + ENTRY_METHOD + "(...) 导出入口"
+                        + "(规划师导出其名下客户档案,导出范围显式入参,TC-T-29 正常流)。实现未落地:宿主类不存在。");
+    }
+
+    // ==================== 边界/异常流(TC-T-29-E):范围入参 + 统一 Result ====================
+
+    @Test
+    @DisplayName("边界流-1:导出范围显式入参(Map 或数值 ID 载体)——越授权可拒 / 空范围可拒绝")
+    void exportEntry_hasExplicitScopeParam() {
+        Class<?> clazz = hostClass();
+        Method entry = findMethod(clazz, ENTRY_METHOD, 1);
+        assertNotNull(entry, "[teacher.client.export] 导出入口缺失(宿主未实现),无法校验范围入参契约。");
+        boolean scopeAware = false;
+        for (Class<?> p : entry.getParameterTypes()) {
+            if (java.util.Map.class.isAssignableFrom(p) || Number.class.isAssignableFrom(p)) {
+                scopeAware = true;
+                break;
+            }
+        }
+        assertTrue(scopeAware,
+                "[teacher.client.export] 导出入口应携带显式导出范围(Map 参数或 familyId/memberId 数值 ID),"
+                        + "支撑「仅导出绑定客户 / 越授权可拒 / 空范围返回错误」(TC-T-29-E 边界流;"
+                        + "对齐 API_REFERENCE §4.61.5 缺口 G-05 教训:无 memberId 入参则无按范围导出)。");
+    }
+
+    @Test
+    @DisplayName("异常流-1:导出入口统一返回 Result 包装(失败 Result.error,不裸抛异常)")
+    void exportEntry_returnsUnifiedResult() {
+        Class<?> clazz = hostClass();
+        Method entry = findMethod(clazz, ENTRY_METHOD, 1);
+        assertNotNull(entry, "[teacher.client.export] 导出入口缺失(宿主未实现),无法校验返回契约。");
+        String ret = entry.getReturnType().getName();
+        assertTrue(ret.contains("Result"),
+                "[teacher.client.export] 导出入口应统一返回 Result 包装(成功 Result.success(payload) / "
+                        + "失败 Result.error),不得裸抛异常或返回裸 Map(TC-T-29-E 异常流;仓库统一响应约定)。"
+                        + "当前返回类型:" + ret);
+    }
+
+    // ==================== 权限流(TC-T-29-P):teacher 角色守卫 + 绑定授权源 ====================
+
+    @Test
+    @DisplayName("权限流-1:注入 GuideFamilyAccessGuard 授权守卫或方法携带角色上下文(绑定客户才可导出)")
+    void exportEntry_teacherRoleGuardOrAuthorityGuard() {
+        Class<?> clazz = hostClass();
+        Method entry = findMethod(clazz, ENTRY_METHOD, 1);
+        assertNotNull(entry, "[teacher.client.export] 导出入口缺失(宿主未实现),无法校验角色守卫契约。");
+        boolean guarded = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (AUTHORITY_GUARD.equals(f.getType().getName())) {
+                guarded = true;
+                break;
+            }
+        }
+        if (!guarded && entry.getParameterCount() >= 3) {
+            // Controller 惯例:params + userId + role(@RequestAttribute),角色上下文在签名中
+            guarded = true;
+        }
+        assertTrue(guarded,
+                "[teacher.client.export] 导出入口须有 teacher 角色守卫:注入 " + AUTHORITY_GUARD
+                        + "(全项目唯一授权判定点,guide_families.status='binding' isBound 授权源,SC-T-13 同源)"
+                        + "或导出方法携带角色上下文参数(params+userId+role)——非绑定客户不得导出、非 teacher 角色拒绝"
+                        + "(TC-T-29-P 权限流)。实现未落地:宿主类不存在。");
+    }
+
+    // ==================== 合规流:敏感字段脱敏契约(对齐 SC-S-11 口径) ====================
+
+    @Test
+    @DisplayName("合规流-1:客户档案导出须强制脱敏(phone/realName/idCard,对齐 SC-S-11)")
+    void export_mandatoryMaskingContract() {
+        Class<?> clazz = hostClass();
+        assertNotNull(clazz,
+                "[teacher.client.export] FamilyExportController 未实现(合规流前置缺失):客户档案导出为合规能力,"
+                        + "导出 phone/realName/idCard 等敏感字段须强制脱敏/最小化(复用 DataMaskUtil,"
+                        + "对齐 SC-S-11 supplier.data.exportCompliance 契约口径)。宿主类存在后本契约随正常流用例转绿。");
+    }
+
+    // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
+
+    /** 解析宿主类;不存在即断言失败(RED 信号:实现未落地),并清晰贴出缺口。 */
+    private Class<?> hostClass() {
+        try {
+            return Class.forName(HOST_CLASS);
+        } catch (ClassNotFoundException e) {
+            fail("RED[teacher.client.export] 契约宿主 " + HOST_CLASS + " 未实现(实现未落地):"
+                    + "SC-T-29 客户档案数据导出(合规)规划师侧导出入口缺失(契约缺口 G-3:"
+                    + "AdminOrgMemberController 仅 list/add/remove/change-type,无 export;"
+                    + "UserController /data-export 仅为登录用户本人的个保法可携带权导出)。"
+                    + "缺失详情:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 按方法名 + 最小参数个数匹配,返回首个命中;无则返回 null。 */
+    private Method findMethod(Class<?> clazz, String name, int minParams) {
+        if (clazz == null) {
+            return null;
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name) && m.getParameterCount() >= minParams) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+}