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