Просмотр исходного кода

merge(S-cfc-190): teacher.family.bind 契约级单测(TDD-RED 审批流认证闸门缺口)

Agnes Agent 1 час назад
Родитель
Сommit
391742c2d5
1 измененных файлов с 405 добавлено и 0 удалено
  1. 405 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherFamilyBindContractTest.java

+ 405 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherFamilyBindContractTest.java

@@ -0,0 +1,405 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.family.BindController;
+import com.etotem.cfc.controller.family.TeacherFamilyBindingController;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.entity.TeacherFamilyBindingRequest;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
+import com.etotem.cfc.mapper.TeacherFamilyBindingRequestMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.GuideFamilyQueryService;
+import com.etotem.cfc.service.GuideFamilyService;
+import com.etotem.cfc.service.TeacherFamilyBindingRequestService;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.List;
+import java.util.ArrayList;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:能力键 {@code teacher.family.bind}(SC-T-02 绑定测试家庭 / SC-T-24 解绑/重分配客户家庭)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>溯源·需求:{@code docs/tdd/orchestration/sc-key-map.md} SC-T-02 / SC-T-24;EVD-10 契约
+ *       ({@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} §2 端点核验 + §3 键映射)</li>
+ *   <li>溯源·设计:API_REFERENCE §4.57(成长规划师域键映射)、§4.45(规划师解除家庭绑定)、
+ *       §4.47(规划师审核工作台,授权源 guide_families.status='binding')、§5.4(PlannerCertificationGuard 接单闸门)</li>
+ * </ul>
+ *
+ * <p><b>契约锚点(两类流)</b></p>
+ * <ul>
+ *   <li><b>绑定授权源</b>:家庭↔规划师绑定以 {@code guide_families.status='binding'} 为唯一授权源;
+ *       {@code GuideFamilyService.isBound} / {@code GuideFamilyQueryService.listMyBoundFamilies} 同源,
+ *       且绑定冲突守卫「该家庭已绑定其他成长规划师,请先解除当前绑定」。</li>
+ *   <li><b>解绑状态机</b>:解绑置 {@code guide_families.status='cancelled'},按 {@code id+guide_id/family_id+status='binding'}
+ *       定位,只能解除属于自己的绑定,不存在/已解除抛「绑定关系不存在或已解除」。</li>
+ *   <li><b>绑定审批闸门(RED 锚点)</b>:API_REFERENCE §5.4 规定「家庭绑定(开始服务)」为接单闸门入口,
+ *       需 {@code PlannerCertificationGuard.assertCertified(guideId, planner)} 拦截未认证规划师。
+ *       直连绑定 {@code BindController.accept} / {@code BindInviteController.confirm} 已挂闸门,
+ *       但<b>审批流</b>({@code TeacherFamilyBindingController.approve} / 申请流 createRequest)<b>未挂认证闸门</b>,
+ *       属同一「开始服务」语义的旁路缺口。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯 Mockito + 反射,无 Spring / 无 DB / 无真实微信。行为锁定用例(GREEN)
+ * 钉住已落地的绑定/解绑/授权源行为;RED 用例探测审批流认证闸门缺口。</p>
+ *
+ * <p><b>RED 预期</b>:审批流认证闸门未落地({@code TeacherFamilyBindingController} 无
+ * {@code PlannerCertificationGuard} 依赖、{@code approve} 未调用闸门),认证未通过仍放行写库,
+ * 2 条审批闸门用例在实现补齐前应失败(红)。</p>
+ */
+class TeacherFamilyBindContractTest {
+
+    private static final Long GUIDE_ID = 7L;
+    private static final Long FAMILY_ID = 100L;
+    private static final Long FAMILY_OWNER_USER_ID = 1L;
+    private static final Long BINDING_RECORD_ID = 12L;
+    private static final Long REQUEST_ID = 5L;
+    private static final String MSG_CONFLICT = "该家庭已绑定其他成长规划师,请先解除当前绑定";
+    private static final String MSG_NOT_FOUND = "绑定关系不存在或已解除";
+
+    @Mock
+    private GuideFamilyMapper guideFamilyMapper;
+
+    @Mock
+    private FamilyMapper familyMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private TeacherFamilyBindingRequestMapper bindingRequestMapper;
+
+    @InjectMocks
+    private GuideFamilyService guideFamilyService;
+
+    @InjectMocks
+    private TeacherFamilyBindingRequestService bindingRequestService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ==================== 一、行为锁定(GREEN):绑定授权源 + 冲突守卫 ====================
+
+    @Test
+    @DisplayName("SC-T-02 GREEN:家庭已绑定其他规划师 → confirmBind 冲突守卫拒绝")
+    void confirmBind_家庭已绑定其他规划师_冲突拒绝() {
+        // 第一个 selectOne(查 family_id+status='binding'+ne guide_id)命中已有绑定
+        when(guideFamilyMapper.selectOne(ArgumentMatchers.<Wrapper<GuideFamily>>any()))
+                .thenReturn(new GuideFamily());
+
+        java.lang.RuntimeException ex = org.junit.jupiter.api.Assertions.assertThrows(
+                java.lang.RuntimeException.class,
+                () -> guideFamilyService.confirmBind(GUIDE_ID, FAMILY_ID, "consulting", 0));
+        assertEquals(MSG_CONFLICT, ex.getMessage());
+        // 冲突分支不写库
+        verify(guideFamilyMapper, never()).insert(any(GuideFamily.class));
+        verify(guideFamilyMapper, never()).updateById(any(GuideFamily.class));
+    }
+
+    @Test
+    @DisplayName("SC-T-02 GREEN:无冲突 + 命中历史解绑行 → confirmBind 复位 status='binding'(重绑生效)")
+    void confirmBind_无冲突_命中历史行_复位binding() {
+        // 第一次(冲突查询)返回 null(无他人绑定),第二次(自身行)返回历史解绑行
+        GuideFamily historical = new GuideFamily();
+        historical.setId(BINDING_RECORD_ID);
+        historical.setGuideId(GUIDE_ID);
+        historical.setFamilyId(FAMILY_ID);
+        historical.setStatus("cancelled");
+        when(guideFamilyMapper.selectOne(ArgumentMatchers.<Wrapper<GuideFamily>>any()))
+                .thenReturn(null, historical);
+        when(guideFamilyMapper.updateById(any(GuideFamily.class))).thenReturn(1);
+
+        boolean ok = guideFamilyService.confirmBind(GUIDE_ID, FAMILY_ID, "consulting", 0);
+        assertTrue(ok, "重绑(历史 cancelled 行复位)应返回 true");
+        org.mockito.ArgumentCaptor<GuideFamily> captor =
+                org.mockito.ArgumentCaptor.forClass(GuideFamily.class);
+        verify(guideFamilyMapper).updateById(captor.capture());
+        assertEquals("binding", captor.getValue().getStatus(), "重绑必须复位 status='binding',否则 isBound 永远 false");
+    }
+
+    @Test
+    @DisplayName("SC-T-02 GREEN:无冲突 + 无历史行 → confirmBind 新建绑定 status='binding'")
+    void confirmBind_无冲突_新建绑定() {
+        when(guideFamilyMapper.selectOne(ArgumentMatchers.<Wrapper<GuideFamily>>any()))
+                .thenReturn(null, null);
+        when(guideFamilyMapper.insert(any(GuideFamily.class))).thenReturn(1);
+
+        boolean ok = guideFamilyService.confirmBind(GUIDE_ID, FAMILY_ID, "monthly", 29900);
+        assertTrue(ok, "新建绑定应返回 true");
+        org.mockito.ArgumentCaptor<GuideFamily> captor =
+                org.mockito.ArgumentCaptor.forClass(GuideFamily.class);
+        verify(guideFamilyMapper).insert(captor.capture());
+        assertEquals("binding", captor.getValue().getStatus());
+        assertEquals(FAMILY_ID, captor.getValue().getFamilyId());
+        assertEquals(GUIDE_ID, captor.getValue().getGuideId());
+    }
+
+    // ==================== 二、行为锁定(GREEN):解绑状态机 ====================
+
+    @Test
+    @DisplayName("SC-T-24 GREEN:家庭侧解绑 unbindAndGet → 置 status='cancelled' 并回传 guideId(供通知)")
+    void unbindAndGet_正常_置cancelled回传guideId() {
+        GuideFamily gf = new GuideFamily();
+        gf.setId(BINDING_RECORD_ID);
+        gf.setGuideId(GUIDE_ID);
+        gf.setFamilyId(FAMILY_ID);
+        gf.setStatus("binding");
+        when(guideFamilyMapper.selectOne(ArgumentMatchers.<Wrapper<GuideFamily>>any())).thenReturn(gf);
+        when(guideFamilyMapper.updateById(any(GuideFamily.class))).thenReturn(1);
+
+        GuideFamily result = guideFamilyService.unbindAndGet(BINDING_RECORD_ID, FAMILY_ID);
+        assertEquals("cancelled", result.getStatus(), "解绑必须置 status='cancelled'");
+        assertEquals(GUIDE_ID, result.getGuideId(), "需回传 guideId 供解绑通知");
+        verify(guideFamilyMapper).updateById(any(GuideFamily.class));
+    }
+
+    @Test
+    @DisplayName("SC-T-24 GREEN:家庭侧解绑记录不存在/已解除 → 抛「绑定关系不存在或已解除」")
+    void unbindAndGet_不存在_抛错() {
+        when(guideFamilyMapper.selectOne(ArgumentMatchers.<Wrapper<GuideFamily>>any())).thenReturn(null);
+        java.lang.RuntimeException ex = org.junit.jupiter.api.Assertions.assertThrows(
+                java.lang.RuntimeException.class,
+                () -> guideFamilyService.unbindAndGet(BINDING_RECORD_ID, FAMILY_ID));
+        assertEquals(MSG_NOT_FOUND, ex.getMessage());
+        verify(guideFamilyMapper, never()).updateById(any(GuideFamily.class));
+    }
+
+    @Test
+    @DisplayName("SC-T-24 GREEN:规划师侧解绑 unbindByGuide → 置 status='cancelled'(只能解自己的绑定)")
+    void unbindByGuide_正常_置cancelled() {
+        GuideFamily gf = new GuideFamily();
+        gf.setId(BINDING_RECORD_ID);
+        gf.setGuideId(GUIDE_ID);
+        gf.setFamilyId(FAMILY_ID);
+        gf.setStatus("binding");
+        when(guideFamilyMapper.selectOne(ArgumentMatchers.<Wrapper<GuideFamily>>any())).thenReturn(gf);
+        when(guideFamilyMapper.updateById(any(GuideFamily.class))).thenReturn(1);
+
+        GuideFamily result = guideFamilyService.unbindByGuide(BINDING_RECORD_ID, GUIDE_ID);
+        assertEquals("cancelled", result.getStatus());
+        verify(guideFamilyMapper).updateById(any(GuideFamily.class));
+    }
+
+    @Test
+    @DisplayName("SC-T-24 GREEN:规划师解他人绑定 → 按 guide_id 过滤定位不到 → 抛「绑定关系不存在或已解除」")
+    void unbindByGuide_非本人绑定_抛错() {
+        // selectOne 按 id+guide_id+status='binding' 定位;换 guideId 后命中不到(mock 返回 null 模拟越权)
+        when(guideFamilyMapper.selectOne(ArgumentMatchers.<Wrapper<GuideFamily>>any())).thenReturn(null);
+        java.lang.RuntimeException ex = org.junit.jupiter.api.Assertions.assertThrows(
+                java.lang.RuntimeException.class,
+                () -> guideFamilyService.unbindByGuide(BINDING_RECORD_ID, 999L));
+        assertEquals(MSG_NOT_FOUND, ex.getMessage());
+        verify(guideFamilyMapper, never()).updateById(any(GuideFamily.class));
+    }
+
+    // ==================== 三、行为锁定(GREEN):授权源同源(guide_families.status='binding') ====================
+
+    @Test
+    @DisplayName("SC-T-02 GREEN:my-families 授权源与 isBound 同为 guide_families(status='binding'),返回含 familyName/serviceType")
+    void myFamilies_授权源同源_返回绑定家庭() {
+        GuideFamilyQueryService queryService = new GuideFamilyQueryService();
+        inject(queryService, "guideFamilyMapper", guideFamilyMapper);
+        inject(queryService, "familyMapper", familyMapper);
+        inject(queryService, "familyMemberMapper", familyMemberMapper);
+
+        GuideFamily gf = new GuideFamily();
+        gf.setGuideId(GUIDE_ID);
+        gf.setFamilyId(FAMILY_ID);
+        gf.setStatus("binding");
+        gf.setServiceType("consulting");
+        when(guideFamilyMapper.selectList(ArgumentMatchers.<Wrapper<GuideFamily>>any()))
+                .thenReturn(new ArrayList<>(java.util.Collections.singletonList(gf)));
+        Family family = new Family();
+        family.setId(FAMILY_ID);
+        family.setName("契约测试家庭");
+        when(familyMapper.selectBatchIds(any())).thenReturn(new ArrayList<>(java.util.Collections.singletonList(family)));
+        when(familyMemberMapper.selectList(ArgumentMatchers.<Wrapper<com.etotem.cfc.entity.FamilyMember>>any()))
+                .thenReturn(new ArrayList<>(java.util.Arrays.asList(
+                        memberOf(FAMILY_ID), memberOf(FAMILY_ID))));
+
+        List<Map<String, Object>> rows = queryService.listMyBoundFamilies(GUIDE_ID);
+        assertEquals(1, rows.size());
+        assertEquals(FAMILY_ID, rows.get(0).get("familyId"));
+        assertEquals("契约测试家庭", rows.get(0).get("familyName"));
+        assertEquals("consulting", rows.get(0).get("serviceType"));
+        assertEquals(2, rows.get(0).get("memberCount"));
+    }
+
+    @Test
+    @DisplayName("SC-T-02 GREEN:isBound 仅认 status='binding' → cancelled 不放行(授权判定单点)")
+    void isBound_仅binding放行() {
+        when(guideFamilyMapper.selectOne(ArgumentMatchers.<Wrapper<GuideFamily>>any())).thenReturn(null);
+        boolean bound = guideFamilyService.isBound(GUIDE_ID, FAMILY_ID);
+        assertTrue(!bound, "status 非 binding(已解绑/暂停)时 isBound 必须为 false");
+    }
+
+    // ==================== 四、行为锁定(GREEN):绑定审批成功写库 ====================
+
+    @Test
+    @DisplayName("SC-T-02 GREEN:审批通过 → 写 family.teacherId + user.teacherFamilyIds + status='approved'")
+    void approve_成功_写归属() {
+        TeacherFamilyBindingRequest req = new TeacherFamilyBindingRequest();
+        req.setId(REQUEST_ID);
+        req.setTeacherId(GUIDE_ID);
+        req.setFamilyId(FAMILY_ID);
+        req.setStatus("pending");
+        when(bindingRequestMapper.selectById(REQUEST_ID)).thenReturn(req);
+
+        User approver = new User();
+        approver.setId(FAMILY_OWNER_USER_ID);
+        approver.setFamilyId(FAMILY_ID);
+        when(userMapper.selectById(FAMILY_OWNER_USER_ID)).thenReturn(approver);
+
+        Family family = new Family();
+        family.setId(FAMILY_ID);
+        when(familyMapper.selectById(FAMILY_ID)).thenReturn(family);
+
+        User teacher = new User();
+        teacher.setId(GUIDE_ID);
+        teacher.setTeacherFamilyIds(null);
+        when(userMapper.selectById(GUIDE_ID)).thenReturn(teacher);
+
+        bindingRequestService.approve(REQUEST_ID, FAMILY_OWNER_USER_ID);
+
+        assertEquals("approved", req.getStatus(), "审批通过后申请置 approved");
+        assertEquals(GUIDE_ID, family.getTeacherId(), "审批通过应写 family.teacherId");
+        assertNotNull(teacher.getTeacherFamilyIds(), "审批通过应写 user.teacherFamilyIds");
+        assertTrue(teacher.getTeacherFamilyIds().contains(String.valueOf(FAMILY_ID)));
+    }
+
+    // ==================== 五、RED 锚点:绑定审批流认证闸门缺口 ====================
+
+    /**
+     * RED-1(反射结构契约):审批流控制器应注入 {@code PlannerCertificationGuard}。
+     * API_REFERENCE §5.4 规定「家庭绑定(开始服务)」为接单闸门入口(所需类别 planner)。
+     * 直连绑定 BindController / BindInviteController 已挂闸门,审批流 {@code TeacherFamilyBindingController}
+     * 同为「开始服务」语义却未注入闸门 → 审批成为认证旁路。实现补齐(字段注入 + approve 调用闸门)后转绿。
+     */
+    @Test
+    @DisplayName("RED-1:绑定审批控制器应注入 PlannerCertificationGuard(§5.4 接单闸门)")
+    void approveController_应注入认证闸门() {
+        Class<?> clazz = TeacherFamilyBindingController.class;
+        boolean injected = false;
+        for (java.lang.reflect.Field f : clazz.getDeclaredFields()) {
+            if ("com.etotem.cfc.service.PlannerCertificationGuard".equals(f.getType().getName())) {
+                injected = true;
+                break;
+            }
+        }
+        assertTrue(injected,
+                "[teacher.family.bind] TeacherFamilyBindingController 未注入 PlannerCertificationGuard:"
+                        + "API_REFERENCE §5.4 规定家庭绑定(开始服务)需过规划师基础认证闸门,"
+                        + "审批流(/api/family/teacher-bindings/approve)未挂闸门,属认证旁路缺口。实现未落地。");
+    }
+
+    /**
+     * RED-2(行为契约):审批「未认证规划师」应被认证闸门拦截,不落库、不下发。
+     * 现状 approve 无闸门 → 未认证 teacher 的 pending 申请照常 approved 并写 family.teacherId,
+     * 绕过直连绑定(BindController.accept / BindInviteController.confirm)已强制的认证。
+     */
+    @Test
+    @DisplayName("RED-2:审批未认证规划师 → 闸门拦截 403,不落库不下发归属")
+    void approve_未认证规划师_闸门拦截不写库() {
+        TeacherFamilyBindingController controller = new TeacherFamilyBindingController();
+        TeacherFamilyBindingRequestService bindingSvc = mock(TeacherFamilyBindingRequestService.class);
+        UserMapper userMapperMock = mock(UserMapper.class);
+        com.etotem.cfc.service.PlannerCertificationGuard guard =
+                mock(com.etotem.cfc.service.PlannerCertificationGuard.class);
+        inject(controller, "bindingRequestService", bindingSvc);
+        inject(controller, "userMapper", userMapperMock);
+        inject(controller, "wechatService", mock(com.etotem.cfc.service.WechatService.class));
+        // 闸门字段当前不存在(RED 根因)→ 尝试注入,缺失则跳过(approve 无闸门 → 直接写库)
+        tryInject(controller, "plannerCertificationGuard", guard);
+
+        User approver = new User();
+        approver.setId(FAMILY_OWNER_USER_ID);
+        approver.setFamilyId(FAMILY_ID);
+        when(userMapperMock.selectById(FAMILY_OWNER_USER_ID)).thenReturn(approver);
+        // 规划师未通过基础认证(planner)→ 闸门应拒绝
+        when(guard.assertCertified(GUIDE_ID,
+                com.etotem.cfc.service.PlannerCertificationService.CATEGORY_PLANNER))
+                .thenReturn(Result.error(403, "规划师未完成【planner】培训认证,暂不可提供该类服务"));
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("requestId", REQUEST_ID);
+        body.put("teacherId", GUIDE_ID);
+        Result<String> r = controller.approve(FAMILY_OWNER_USER_ID, body);
+
+        assertEquals(403, r.getCode(), "未认证规划师的绑定审批应被认证闸门拦截(403)");
+        assertTrue(r.getMessage() != null && r.getMessage().contains("培训认证"),
+                "拦截文案应指明认证要求");
+        verify(bindingSvc, never()).approve(anyLong(), anyLong());
+    }
+
+    // ==================== 反射/注入辅助 ====================
+
+    private static com.etotem.cfc.entity.FamilyMember memberOf(Long familyId) {
+        com.etotem.cfc.entity.FamilyMember m = new com.etotem.cfc.entity.FamilyMember();
+        m.setFamilyId(familyId);
+        return m;
+    }
+
+    /** 反射注入字段(同批契约测试风格:构造后注入 mock,无 Spring)。 */
+    private static void inject(Object target, String fieldName, Object value) {
+        boolean done = tryInject(target, fieldName, value);
+        if (!done) {
+            fail("注入字段 " + fieldName + " 失败:字段不存在于 " + target.getClass().getName());
+        }
+    }
+
+    /** 尝试反射注入;字段不存在(RED 根因之一:闸门未落地)返回 false 而非抛错。 */
+    private static boolean tryInject(Object target, String fieldName, Object value) {
+        try {
+            java.lang.reflect.Field f = findField(target.getClass(), fieldName);
+            f.setAccessible(true);
+            f.set(target, value);
+            return true;
+        } catch (NoSuchFieldException e) {
+            return false;
+        } catch (Exception e) {
+            fail("注入字段 " + fieldName + " 失败:" + e.getMessage());
+            return false;
+        }
+    }
+
+    private static java.lang.reflect.Field findField(Class<?> clazz, String name)
+            throws NoSuchFieldException {
+        for (Class<?> c = clazz; c != null; c = c.getSuperclass()) {
+            try {
+                return c.getDeclaredField(name);
+            } catch (NoSuchFieldException ignored) {
+            }
+        }
+        throw new NoSuchFieldException(name);
+    }
+}