package com.yunhai.flowcenter.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.flowcenter.engine.AssigneeRef; import com.yunhai.flowcenter.engine.AssigneeResolver; import com.yunhai.flowcenter.engine.DefValidator; import com.yunhai.flowcenter.engine.FlowDefModel; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.openapi.base.OpenApiGroupClient; import com.yunhai.platform.openapi.base.OpenApiUserClient; import com.yunhai.platform.openapi.base.model.DepartmentEntry; import com.yunhai.platform.openapi.base.model.DepartmentRef; import com.yunhai.platform.utils.model.BoaUser; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * UserBase 部门权威化与审批主体规则(工作包 C,2026-10-29)。 * *

测试替身边界:{@link OpenApiGroupClient}/{@link OpenApiUserClient} 按 UserBase * {@code /oapi/departments/**} 的真实契约打桩(响应形状=SDK 的 DepartmentEntry/BoaUser), * 这不是跨服务联调——真实 UserBase 可达性、SYSTEM/CUSTOM 数据齐备性由后续 WorkBuddy 验证。

* *

钉住的红线:

* */ @DisplayName("UserBase 部门主体解析 + 展示规则(C1/C2/C3)") class FlDepartmentSubjectTest { @Test void foreignSchoolReferenceCannotExposeMembers() { assertThatThrownBy(() -> directory.resolve(SCH, Arrays.asList(new DepartmentRef((int) SCH + 1, SYSTEM, 42)))) .isInstanceOf(BizException.class).hasMessageContaining("学校"); DepartmentEntry foreign = entry(SYSTEM, 42, true, false); foreign.setSchoolId((int) SCH + 1); foreign.setName("foreign"); when(groupClient.resolveDepartments(any())).thenReturn(Arrays.asList(foreign)); assertThatThrownBy(() -> directory.membersAtSchool(SCH, SYSTEM, 42, "select")) .isInstanceOf(BizException.class).hasMessageContaining("学校"); org.mockito.Mockito.verify(groupClient, org.mockito.Mockito.never()).getDepartmentMembers(any(), any(), any()); } private static final long SCH = 5L; private static final String SYSTEM = "SYSTEM"; private static final String CUSTOM = "CUSTOM"; private OpenApiGroupClient groupClient; private OpenApiUserClient userClient; private UserBaseDepartmentDirectory directory; private AssigneeResolver resolver; private final ObjectMapper mapper = new ObjectMapper(); @BeforeEach void setUp() { groupClient = mock(OpenApiGroupClient.class); userClient = mock(OpenApiUserClient.class); directory = new UserBaseDepartmentDirectory(groupClient); when(groupClient.resolveDepartments(any())).thenAnswer(call -> { java.util.List refs = call.getArgument(0); if (refs == null) return java.util.Collections.emptyList(); java.util.List rows = new java.util.ArrayList<>(); for (DepartmentRef ref : refs) { DepartmentEntry e = entry(ref.getDepartmentType(), ref.getDepartmentId(), true, false); e.setSchoolId(ref.getSchoolId()); e.setName("department"); rows.add(e); } return rows; }); resolver = new AssigneeResolver(mock(FlowDeptAdminService.class), userClient); resolver.setDepartmentDirectory(directory); givenEligibleUsers(101, 102, 103, 104); } // ==================== C1 权威目录接入 ==================== @Test @DisplayName("DEPT 主体从 UserBase 目录解析候选人(scope=select 严档,不读 flw_dept_member)") void departmentSubjectResolvesFromUserBase() { when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")) .thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师"))); List refs = resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42))); assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101, 102); verify(groupClient).getDepartmentMembers(SYSTEM, 42, "select"); } @Test @DisplayName("同一用户归属多个被选部门:只产出一条待办(多组归属不是模型禁忌)") void overlappingMembershipDeduplicatesUser() { when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")) .thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师"))); when(groupClient.getDepartmentMembers(CUSTOM, 7, "select")) .thenReturn(Arrays.asList(user(102, "李老师"), user(103, "王老师"))); FlowDefModel.Assignee assignee = new FlowDefModel.Assignee(); assignee.type = FlowDefModel.ASSIGNEE_DEPT; assignee.deptRefs = new ArrayList<>(Arrays.asList(deptRef(SYSTEM, 42), deptRef(CUSTOM, 7))); List refs = resolver.resolve(SCH, approvalNode(assignee)); assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101, 102, 103); } @Test @DisplayName("目录不可用:拒绝并说明原因,绝不退回 FL 本地科室(双权威 fallback 已禁)") void directoryOutageFailsClosed() { when(groupClient.getDepartmentMembers(anyString(), anyInt(), anyString())) .thenThrow(new RuntimeException("userbase unreachable")); FlowDefModel.Node node = approvalNode(deptAssignee(SYSTEM, 42)); assertThatThrownBy(() -> resolver.resolve(SCH, node)) .isInstanceOf(BizException.class) .hasMessageContaining("不可用"); // 未装配目录通道同样拒绝,而不是回读本地表 AssigneeResolver unwired = new AssigneeResolver(mock(FlowDeptAdminService.class), userClient); assertThatThrownBy(() -> unwired.resolve(SCH, node)) .isInstanceOf(BizException.class) .hasMessageContaining("未装配"); } @Test @DisplayName("部门解析出 0 人:按「无可用办理人」拒绝,不把空候选当成功") void emptyDepartmentIsRejected() { when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")).thenReturn(new ArrayList<>()); assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42)))) .isInstanceOf(BizException.class) .hasMessageContaining("无可用办理人"); } @Test @DisplayName("部门里全是禁用/跨校账号:整体拒绝并点名原因(存在≠有资格,D11)") void ineligibleMembersAreFilteredAndEmptyIsRejected() { when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")) .thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师"))); when(userClient.getUserById("101")).thenReturn(disabled(101)); when(userClient.getUserById("102")).thenReturn(otherSchool(102)); assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42)))) .isInstanceOf(BizException.class) .hasMessageContaining("不具备办理资格"); } @Test @DisplayName("部分成员不合格:剔除不合格者继续,不因个别人卡死整条流程") void partiallyIneligibleKeepsEligibleOnes() { when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")) .thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师"))); when(userClient.getUserById("102")).thenReturn(disabled(102)); List refs = resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42))); assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101); } @Test @DisplayName("部门引用缺类型消歧位:拒绝(同值 id 在 SYSTEM/CUSTOM 不等价)") void untypedReferenceRejected() { FlowDefModel.Assignee assignee = new FlowDefModel.Assignee(); assignee.type = FlowDefModel.ASSIGNEE_DEPT; FlowDefModel.DeptRef bad = new FlowDefModel.DeptRef(); bad.departmentId = 42; assignee.deptRefs = new ArrayList<>(Arrays.asList(bad)); assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(assignee))) .isInstanceOf(BizException.class); assertThatThrownBy(() -> directory.members("OTHER", 42, "select")) .isInstanceOf(BizException.class) .hasMessageContaining("SYSTEM/CUSTOM"); } @Test @DisplayName("历史口径与选择口径分开:resolve-batch 取停用部门名称,select 不返回停用项") void historyAndSelectionScopesAreSeparate() { when(groupClient.getDepartmentMembers(CUSTOM, 9, "history")) .thenReturn(Arrays.asList(user(103, "王老师"))); assertThat(directory.members(CUSTOM, 9, "history")) .extracting(BoaUser::getId).containsExactly(103); DepartmentEntry deleted = new DepartmentEntry(); deleted.setDepartmentType(CUSTOM); deleted.setDepartmentId(9); deleted.setName("已撤销的课题组"); deleted.setSelectable(false); deleted.setDeleted(true); deleted.setSchoolId((int) SCH); when(groupClient.resolveDepartments(any())).thenReturn(Arrays.asList(deleted)); List resolved = directory.resolve(SCH, Arrays.asList(new DepartmentRef(null, CUSTOM, 9))); assertThat(resolved).hasSize(1); assertThat(resolved.get(0).getName()).isEqualTo("已撤销的课题组"); assertThat(resolved.get(0).getSelectable()).isFalse(); // 学校范围由服务端会话派生并补进引用,不接受前端传别校 id assertThat(resolved.get(0).getSchoolId()).isEqualTo((int) SCH); ArgumentCaptor> sent = ArgumentCaptor.forClass(List.class); verify(groupClient).resolveDepartments(sent.capture()); assertThat(sent.getValue().get(0).getSchoolId()).as("校域由服务端会话派生并补进引用").isEqualTo((int) SCH); } @Test @DisplayName("发布校验白名单来自目录 selectable 项,停用部门不可被新选中") void selectableKeysOnlyCatalogSelectable() { DepartmentEntry live = entry(SYSTEM, 42, true, false); DepartmentEntry stopped = entry(SYSTEM, 43, false, false); DepartmentEntry softDeleted = entry(CUSTOM, 9, true, true); when(groupClient.getDepartments(eq((int) SCH), any(), any(), any())) .thenReturn(Arrays.asList(live, stopped, softDeleted)); assertThat(directory.selectableKeys(SCH)).containsExactly("SYSTEM:42"); } @Test @DisplayName("空引用批量解析本地短路,不出站也不报错") void emptyResolveBatchShortCircuits() { assertThat(directory.resolve(SCH, new ArrayList<>())).isEmpty(); verify(groupClient, never()).resolveDepartments(any()); } // ==================== C3 展示规则 ==================== @Test @DisplayName("显示部门:任务节点名取独立展示名;未配展示字段=沿用节点名(历史定义零变化)") void displayLabelDrivesTaskNodeName() throws Exception { FlowDefModel model = FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[]}"); FlowDefModel.Node legacy = approvalNode(deptAssignee(SYSTEM, 42)); assertThat(legacy.displayName()).isEqualTo("二级审批"); legacy.displayMode = FlowDefModel.DISPLAY_MODE_DEPT; legacy.displayLabel = "校长室"; assertThat(legacy.displayName()).isEqualTo("校长室"); // 按人员展示时不受 displayLabel 影响(只展示本节点自己配置/解析到的人) legacy.displayMode = FlowDefModel.DISPLAY_MODE_USER; assertThat(legacy.displayName()).isEqualTo("二级审批"); assertThat(model).isNotNull(); } @Test @DisplayName("发布校验:显示部门必须填展示名;未知 displayMode 取值拒绝") void publishRejectsMissingDisplayLabel() { FlowDefModel model = twoLevelModel(deptAssignee(SYSTEM, 42)); model.nodes.get(1).displayMode = FlowDefModel.DISPLAY_MODE_DEPT; assertThatThrownBy(() -> DefValidator.validate(model, null, null, new java.util.HashSet<>(Arrays.asList("SYSTEM:42")))) .isInstanceOf(BizException.class) .hasMessageContaining("展示名称"); model.nodes.get(1).displayMode = "BOTH"; model.nodes.get(1).displayLabel = "校长室"; assertThatThrownBy(() -> DefValidator.validate(model, null, null, new java.util.HashSet<>(Arrays.asList("SYSTEM:42")))) .isInstanceOf(BizException.class) .hasMessageContaining("displayMode"); } @Test @DisplayName("发布校验:部门不在本校可选目录内(跨校/停用/已删)即拦;引用齐备则放行") void publishChecksDepartmentAgainstDirectoryWhitelist() { FlowDefModel model = twoLevelModel(deptAssignee(SYSTEM, 42)); DefValidator.validate(model, null, null, new java.util.HashSet<>(Arrays.asList("SYSTEM:42"))); assertThatThrownBy(() -> DefValidator.validate(model, null, null, new java.util.HashSet<>(Arrays.asList("SYSTEM:99")))) .isInstanceOf(BizException.class) .hasMessageContaining("不在本校可选部门目录内"); } @Test @DisplayName("会签/或签对部门主体与人员主体同样可配(D07/D08 主体定候选,策略定完成条件)") void countersignConfigurableForBothSubjects() { Set whitelist = new java.util.HashSet<>(Arrays.asList("SYSTEM:42")); // 部门主体 + 或签:合法(会签/或签不是只对部门生效) FlowDefModel deptModel = twoLevelModel(deptAssignee(SYSTEM, 42)); deptModel.nodes.get(2).countersign = FlowDefModel.COUNTERSIGN_ANY; DefValidator.validate(deptModel, null, null, whitelist); assertThat(deptModel.nodes.get(2).countersign).isEqualTo(FlowDefModel.COUNTERSIGN_ANY); // 人员主体 + 或签:同样合法 FlowDefModel usersModel = twoLevelModel(usersAssignee(101, 102)); usersModel.nodes.get(2).countersign = FlowDefModel.COUNTERSIGN_ANY; DefValidator.validate(usersModel, null, null, whitelist); // 非法策略值:两类主体都拦 deptModel.nodes.get(2).countersign = "MOST"; assertThatThrownBy(() -> DefValidator.validate(deptModel, null, null, whitelist)) .isInstanceOf(BizException.class) .hasMessageContaining("countersign"); } // ==================== fixtures ==================== private void givenEligibleUsers(int... ids) { for (int id : ids) { when(userClient.getUserById(String.valueOf(id))).thenReturn(sameSchool(id, "教师" + id)); } } private FlowDefModel.Node approvalNode(FlowDefModel.Assignee assignee) { FlowDefModel.Node node = new FlowDefModel.Node(); node.id = "n2"; node.type = FlowDefModel.TYPE_APPROVAL; node.name = "二级审批"; node.assignee = assignee; return node; } private FlowDefModel twoLevelModel(FlowDefModel.Assignee assignee) { FlowDefModel model = FlowDefModel.parse(mapper, "{\"start\":\"start\",\"nodes\":[" + "{\"id\":\"start\",\"type\":\"START\"}," + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"一级\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[101]}}," + "{\"id\":\"end\",\"type\":\"END\"}],\"edges\":[" + "{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}"); FlowDefModel.Node deptNode = new FlowDefModel.Node(); deptNode.id = "n2"; deptNode.type = FlowDefModel.TYPE_APPROVAL; deptNode.name = "二级审批"; deptNode.assignee = assignee; model.nodes.add(2, deptNode); model.edges.add(edge("n1", "n2")); model.edges.add(edge("n2", "end")); // 一级→end 的原边由 n1→n2 取代 model.edges.removeIf(e -> "n1".equals(e.from) && "end".equals(e.to)); return model; } private FlowDefModel.Edge edge(String from, String to) { FlowDefModel.Edge e = new FlowDefModel.Edge(); e.from = from; e.to = to; return e; } private FlowDefModel.Assignee deptAssignee(String type, int id) { FlowDefModel.Assignee assignee = new FlowDefModel.Assignee(); assignee.type = FlowDefModel.ASSIGNEE_DEPT; assignee.deptRefs = new ArrayList<>(Arrays.asList(deptRef(type, id))); return assignee; } private FlowDefModel.Assignee usersAssignee(int... ids) { FlowDefModel.Assignee assignee = new FlowDefModel.Assignee(); assignee.type = FlowDefModel.ASSIGNEE_USERS; List list = new ArrayList<>(); for (int id : ids) { list.add(id); } assignee.userIds = list; return assignee; } private FlowDefModel.DeptRef deptRef(String type, int id) { FlowDefModel.DeptRef ref = new FlowDefModel.DeptRef(); ref.departmentType = type; ref.departmentId = id; return ref; } private DepartmentEntry entry(String type, int id, boolean enabled, boolean deleted) { DepartmentEntry e = new DepartmentEntry(); e.setSchoolId((int) SCH); e.setDepartmentType(type); e.setDepartmentId(id); e.setName(type + "-" + id); e.setEnabled(enabled); e.setDeleted(deleted); e.setSelectable(enabled && !deleted); return e; } private BoaUser user(int id, String name) { return sameSchool(id, name); } private BoaUser sameSchool(int id, String name) { BoaUser u = new BoaUser(); u.setId(id); u.setName(name); u.setStatus(1); com.yunhai.platform.utils.model.BoaGroup school = new com.yunhai.platform.utils.model.BoaGroup(); school.setId((int) SCH); u.setSchool(school); return u; } private BoaUser disabled(int id) { BoaUser u = sameSchool(id, "停用账号"); u.setStatus(0); return u; } private BoaUser otherSchool(int id) { BoaUser u = sameSchool(id, "他校账号"); u.getSchool().setId((int) SCH + 1); return u; } }