Procházet zdrojové kódy

test(S-cfc-252): butler.oneOnOne.coach 契约级 RED 单测

BUTlerOneOnOneCoachContractTest:以反射结构契约 + schema.sql 快照探测
SC-H-02(建档→定期回访→随时咨询;SLA 可配)与 SC-H-03(收集→同步→反馈闭环)
对应宿主(ButlerCoachingProfile/ButlerFollowUpRecord/ButlerConsultationRecord/
ButlerSlaConfig/ButlerCoachingService)与四张建表缺失,预期 RED。
当前 15 用例 14 失败 1 绿(通知落点已具备说明性用例)。
Sisyphus Agent před 14 hodinami
rodič
revize
6d420e87f5

+ 271 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ButlerOneOnOneCoachContractTest.java

@@ -0,0 +1,271 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Scanner;
+
+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 butler.oneOnOne.coach})。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>场景:{@code tests/user-scenarios/应用场景总览.md}
+ *       <b>SC-H-02</b>(成长管家建档→定期回访→随时咨询;回访/响应 SLA 可配)
+ *       与 <b>SC-H-03</b>(需求收集→进度同步→反馈闭环;闭环为方案确认后的反馈回流,不变更方案)。</li>
+ *   <li>派生用例:{@code tests/test-cases/butler.md}
+ *       <b>TC-H-02 / TC-H-02-E / TC-H-02-P</b>(建档/回访/咨询、SLA 超时、跨家庭不可见)
+ *       与 <b>TC-H-03 / TC-H-03-E / TC-H-03-P</b>(收集/同步/反馈闭环、重复同步幂等、跨家庭不可见)。</li>
+ *   <li>接口:{@code docs/superpowers/api/API_REFERENCE.md}
+ *       <b>§4.13</b>({@code /api/butler/coaching/*} 一对一教练建档/回访/咨询)与
+ *       <b>§4.64.8</b>({@code /api/admin/butler/*} 管家端档案/回访/SLA 配置)。
+ *       通知复用已具备的 {@code teacher.message.push} 同源站内通道(见 {@code TeacherMessagePushContractTest})。</li>
+ *   <li>能力键映射:{@code docs/tdd/orchestration/sc-key-map.md}
+ *       <b>SC-H-02 / SC-H-03 → butler.oneOnOne.coach</b>(第 265-266 行)。</li>
+ * </ul>
+ *
+ * <p><b>实现现状</b>:本分支 src/main 中管家已落地「申请/审核/分配/结算」({@code ButlerService} /
+ * {@code AdminButlerController}),但 SC-H-02「建档→定期回访→随时咨询;SLA 可配」与
+ * SC-H-03「收集→同步→反馈闭环」两套契约宿主(档案/回访/咨询/SLA 实体、{@code ButlerCoachingService}
+ * 服务、对应建表)均未落地(全库 0 引用,schema.sql 无对应建表)。因此本测试以「反射结构契约 +
+ * schema.sql 快照」探测实现缺失:能编译通过、能运行、断言失败(红),且失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者创建 {@code ButlerCoachingProfile} / {@code ButlerFollowUpRecord} /
+ * {@code ButlerConsultationRecord} / {@code ButlerSlaConfig} 实体与
+ * {@code butler_coaching_profiles} / {@code butler_follow_up_records} / {@code butler_consultations} /
+ * {@code butler_sla_config} 四表,并实现 {@code ButlerCoachingService}({@code createProfile} 建档、
+ * {@code scheduleFollowUp} 定期回访、{@code recordConsultation} 随时咨询、{@code getSlaConfig} SLA 配置、
+ * {@code closeLoop} 反馈闭环)后,本测试自动转绿;届时可将结构探测升级为行为级 mock 验证
+ * ({@code verify(...)})。</p>
+ *
+ * <p><b>权限流说明</b>:TC-H-02-P / TC-H-03-P 的「跨家庭不可见/不可改」由
+ * {@code butler_coaching_profiles.family_id}(及回访/咨询记录 family_id)承载,故权限流以
+ * 「实体携带 familyId 字段」结构锚定,不写独立 403 行为用例(服务宿主缺失,行为流属 GREEN 阶段)。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无外部服务依赖(纯反射 + schema.sql 读取)。</p>
+ */
+class ButlerOneOnOneCoachContractTest {
+
+    // ==================== 契约宿主类(实现未落地,Class.forName 探测) ====================
+
+    /** SC-H-02:一对一教练档案实体。 */
+    private static final String ENTITY_PROFILE = "com.etotem.cfc.entity.ButlerCoachingProfile";
+
+    /** SC-H-02:定期回访记录实体。 */
+    private static final String ENTITY_FOLLOW_UP = "com.etotem.cfc.entity.ButlerFollowUpRecord";
+
+    /** SC-H-02/SC-H-03:随时咨询记录实体。 */
+    private static final String ENTITY_CONSULTATION = "com.etotem.cfc.entity.ButlerConsultationRecord";
+
+    /** SC-H-02:回访/响应 SLA 配置实体。 */
+    private static final String ENTITY_SLA_CONFIG = "com.etotem.cfc.entity.ButlerSlaConfig";
+
+    /** SC-H-02/SC-H-03:一对一教练服务宿主(建档/回访/咨询/SLA/闭环)。 */
+    private static final String HOST_COACHING_SERVICE = "com.etotem.cfc.service.ButlerCoachingService";
+
+    // ==================== 契约常量 ====================
+
+    /** 四张新表(SC-H-02 档案/回访/咨询/SLA)。 */
+    private static final String[] NEW_TABLES = {
+            "butler_coaching_profiles", "butler_follow_up_records",
+            "butler_consultations", "butler_sla_config"
+    };
+
+    // ==================== SC-H-02 正常流:建档→定期回访→随时咨询(宿主类 + 实体) ====================
+
+    @Test
+    @DisplayName("SC-H-02 正常-1:ButlerCoachingProfile 教练档案实体应存在(表 butler_coaching_profiles)")
+    void coachingProfile_entityExists() {
+        assertContractClass(ENTITY_PROFILE,
+                "ButlerCoachingProfile 实体未实现:SC-H-02 要求「建档案→定期回访→随时咨询」,"
+                        + "需 butler_coaching_profiles 表承载一对一教练档案。");
+    }
+
+    @Test
+    @DisplayName("SC-H-02 正常-2:ButlerFollowUpRecord 定期回访记录实体应存在(表 butler_follow_up_records)")
+    void followUpRecord_entityExists() {
+        assertContractClass(ENTITY_FOLLOW_UP,
+                "ButlerFollowUpRecord 实体未实现:SC-H-02 要求「定期回访」,"
+                        + "需 butler_follow_up_records 表记录每次回访(时间/内容/结论)。");
+    }
+
+    @Test
+    @DisplayName("SC-H-02 正常-3:ButlerConsultationRecord 随时咨询记录实体应存在(表 butler_consultations)")
+    void consultationRecord_entityExists() {
+        assertContractClass(ENTITY_CONSULTATION,
+                "ButlerConsultationRecord 实体未实现:SC-H-02 要求「随时咨询」,"
+                        + "需 butler_consultations 表记录咨询(诉求/响应/处理)。");
+    }
+
+    @Test
+    @DisplayName("SC-H-02 正常-4:ButlerSlaConfig SLA 配置实体应存在(表 butler_sla_config)")
+    void slaConfig_entityExists() {
+        assertContractClass(ENTITY_SLA_CONFIG,
+                "ButlerSlaConfig 实体未实现:SC-H-02 要求回访/响应「SLA 可配」,"
+                        + "需 butler_sla_config 表承载回访周期与响应时限配置。");
+    }
+
+    @Test
+    @DisplayName("SC-H-02 正常-5:ButlerCoachingService 一对一教练服务宿主应存在")
+    void coachingService_classExists() {
+        assertContractClass(HOST_COACHING_SERVICE,
+                "ButlerCoachingService 未实现:SC-H-02/SC-H-03 一对一教练(建档/回访/咨询/SLA/闭环)需服务宿主。");
+    }
+
+    // ==================== SC-H-02 边界流:SLA 可配 / 仅本家庭(实体字段锚定) ====================
+
+    @Test
+    @DisplayName("SC-H-02 边界-1:教练档案实体应携带 familyId(跨家庭不可见的数据基础)")
+    void coachingProfile_carriesFamilyScope() {
+        assertContractField(ENTITY_PROFILE, "familyId",
+                "ButlerCoachingProfile 应含 familyId 字段(butler_coaching_profiles.family_id):"
+                        + "档案必须绑定家庭,才能实现「跨家庭不可见/不可改」(TC-H-02-P)。");
+    }
+
+    @Test
+    @DisplayName("SC-H-02 边界-2:SLA 配置实体应携带回访周期与响应时限(SLA 可配)")
+    void slaConfig_carriesFollowUpAndResponseSla() {
+        assertContractField(ENTITY_SLA_CONFIG, "followUpIntervalDays",
+                "ButlerSlaConfig 应含 followUpIntervalDays 字段:SC-H-02「定期回访」周期可配(回访 SLA)。");
+        assertContractField(ENTITY_SLA_CONFIG, "responseSlaMinutes",
+                "ButlerSlaConfig 应含 responseSlaMinutes 字段:SC-H-02「随时咨询」响应时限可配(响应 SLA)。");
+    }
+
+    // ==================== SC-H-03 正常流:收集→同步→反馈闭环(服务方法) ====================
+
+    @Test
+    @DisplayName("SC-H-02 正常-6:ButlerCoachingService 应提供 createProfile 建档入口")
+    void coachingService_hasCreateProfile() {
+        assertContractMethod(HOST_COACHING_SERVICE, "createProfile", 1,
+                "ButlerCoachingService 应提供 createProfile(familyMemberId) 建档入口:"
+                        + "SC-H-02「建档案」为回访/咨询前置(档案唯一,重复建档收敛)。");
+    }
+
+    @Test
+    @DisplayName("SC-H-02 正常-7:ButlerCoachingService 应提供 scheduleFollowUp 定期回访入口")
+    void coachingService_hasScheduleFollowUp() {
+        assertContractMethod(HOST_COACHING_SERVICE, "scheduleFollowUp", 2,
+                "ButlerCoachingService 应提供 scheduleFollowUp(profileId, operatorId) 定期回访入口:"
+                        + "SC-H-02「定期回访」按 SLA 周期触发并落 butler_follow_up_records。");
+    }
+
+    @Test
+    @DisplayName("SC-H-02 正常-8:ButlerCoachingService 应提供 recordConsultation 随时咨询入口")
+    void coachingService_hasRecordConsultation() {
+        assertContractMethod(HOST_COACHING_SERVICE, "recordConsultation", 2,
+                "ButlerCoachingService 应提供 recordConsultation(profileId, operatorId) 随时咨询入口:"
+                        + "SC-H-02「随时咨询」落 butler_consultations 并受响应 SLA 约束。");
+    }
+
+    @Test
+    @DisplayName("SC-H-02 正常-9:ButlerCoachingService 应提供 getSlaConfig SLA 配置读取入口")
+    void coachingService_hasGetSlaConfig() {
+        assertContractMethod(HOST_COACHING_SERVICE, "getSlaConfig", 1,
+                "ButlerCoachingService 应提供 getSlaConfig(familyId) SLA 配置读取入口:"
+                        + "SC-H-02 回访/响应 SLA 可配(§4.64.8 管家端配置)。");
+    }
+
+    @Test
+    @DisplayName("SC-H-03 正常-1:ButlerCoachingService 应提供 closeLoop 反馈闭环入口")
+    void coachingService_hasCloseLoop() {
+        assertContractMethod(HOST_COACHING_SERVICE, "closeLoop", 2,
+                "ButlerCoachingService 应提供 closeLoop(consultationId, operatorId) 反馈闭环入口:"
+                        + "SC-H-03「收集→同步→反馈闭环」中闭环为方案确认后的反馈回流(不变更方案)。");
+    }
+
+    // ==================== schema.sql 快照:四张新表应落地 ====================
+
+    @Test
+    @DisplayName("schema.sql:四张新表应落地(butler_coaching_profiles/_follow_up_records/_consultations/_sla_config)")
+    void schemaSql_containsFourCoachingTables() {
+        String schema = readSchemaSql();
+        for (String table : NEW_TABLES) {
+            assertTrue(schema.contains(table),
+                    "schema.sql 缺少 " + table + " 建表(SC-H-02 档案/回访/咨询/SLA)。");
+        }
+    }
+
+    // ==================== 权限流(TC-H-02-P / TC-H-03-P 结构锚定) ====================
+
+    @Test
+    @DisplayName("SC-H-02-P/SC-H-03-P 权限流:跨家庭不可见由 family_id 承载(说明性锚定)")
+    void permissionFlow_familyScopeAnchoredByFamilyId() {
+        // 权限流结构锚定:档案/回访/咨询记录必须携带 family_id 才能在查询/操作侧做家庭域校验
+        // (跨家庭不可见/不可改)。宿主服务未实现,403 行为级断言属 GREEN 阶段;
+        // 此处以档案实体 familyId 字段锚定三流覆盖。
+        assertContractField(ENTITY_PROFILE, "familyId",
+                "跨家庭不可见数据基础:ButlerCoachingProfile 必须携带 familyId(TC-H-02-P / TC-H-03-P)。");
+    }
+
+    // ==================== 落点说明:站内通知通道已具备(不缺失断言) ====================
+
+    @Test
+    @DisplayName("通知落点已具备:teacher.message.push 同源站内通道(说明性)")
+    void notifyEndpoint_inAppChannelAlreadyPresent() {
+        // 回访到期/咨询响应/反馈闭环的提醒投递复用已具备的站内通知通道
+        // (同 teacher.message.push,见 TeacherMessagePushContractTest / §4.15)。此通道已实现,
+        // 故不作缺失断言,仅标注「通知落点已具备」;RED 缺口集中在「一对一教练建档/回访/咨询/SLA/闭环」契约宿主。
+        assertNotNull(loadClass("com.etotem.cfc.service.ButlerService"),
+                "管家服务 ButlerService 应存在(已具备,申请/审核/分配/结算)");
+    }
+
+    // ==================== 反射辅助(锚定契约宿主,避免编译期依赖不存在类) ====================
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private void assertContractClass(String className, String why) {
+        assertNotNull(loadClass(className),
+                "RED[butler.oneOnOne.coach] " + why + "(契约宿主 " + className + " 未实现)。");
+    }
+
+    private void assertContractField(String className, String fieldName, String why) {
+        Class<?> clazz = loadClass(className);
+        assertNotNull(clazz, "RED[butler.oneOnOne.coach] " + why + "(实体 " + className + " 未实现)。");
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(fieldName)) {
+                return;
+            }
+        }
+        fail("RED[butler.oneOnOne.coach] " + why + "(实体 " + className + " 缺字段 " + fieldName + ")。");
+    }
+
+    private void assertContractMethod(String className, String methodName, int paramCount, String why) {
+        Class<?> clazz = loadClass(className);
+        assertNotNull(clazz, "RED[butler.oneOnOne.coach] " + why + "(契约宿主 " + className + " 未实现)。");
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(methodName) && m.getParameterCount() == paramCount) {
+                return;
+            }
+        }
+        fail("RED[butler.oneOnOne.coach] " + why + "(契约方法 " + className + "#"
+                + methodName + "(" + paramCount + ") 未实现)。");
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}