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