package com.yunhai.flowcenter.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.flowcenter.controller.api.FlowAdminController; import com.yunhai.flowcenter.controller.api.FlowUserController; import com.yunhai.flowcenter.entity.FlwAssigneeMap; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwInstance; import com.yunhai.flowcenter.entity.FlwTask; import com.yunhai.flowcenter.engine.AssigneeResolver; import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.model.FlowModels; import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; import com.yunhai.flowcenter.repository.FlwBranchRepository; import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.flowcenter.repository.FlwInstanceRepository; import com.yunhai.flowcenter.repository.FlwTaskRepository; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.openapi.base.OpenApiUserClient; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.context.ApplicationEventPublisher; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 流程示意图出图(方案 §3.2 标签解析规则 + §5 TDD C1-C6): * C1/CC3 视图分支与兜底链、C2 UserBase 补名降级、C4 flowGuid 取版、C5/C6 控制器守卫接线。 * 科室视图不泄人员名单(USERS 只显"指定办理人"且不调 UserBase)是硬口径。 */ class FlowDiagramServiceTest { private static final Long SCH = 990001L; /** 并行链:start→sp1→n1(DEPT_KEY rs)→j1→end(教师入职缩样) */ private static final String DEPT_DEF_JSON = "{" + "\"code\":\"teacher_in\",\"name\":\"教师入职\",\"start\":\"start\"," + "\"nodes\":[" + "{\"id\":\"start\",\"type\":\"START\"}," + "{\"id\":\"sp1\",\"type\":\"PARALLEL_SPLIT\"}," + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"人事处审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"rs\"},\"countersign\":\"ALL\",\"deadlineHours\":48}," + "{\"id\":\"j1\",\"type\":\"PARALLEL_JOIN\"}," + "{\"id\":\"end\",\"type\":\"END\"}]," + "\"edges\":[{\"from\":\"start\",\"to\":\"sp1\"},{\"from\":\"sp1\",\"to\":\"n1\"}," + "{\"from\":\"n1\",\"to\":\"j1\"},{\"from\":\"j1\",\"to\":\"end\"}]}"; /** ANY 会签变体(无时限) */ private static final String ANY_DEF_JSON = "{" + "\"code\":\"c2\",\"name\":\"任一通过\",\"start\":\"start\"," + "\"nodes\":[" + "{\"id\":\"start\",\"type\":\"START\"}," + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"甲审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"rs\"},\"countersign\":\"ANY\"}," + "{\"id\":\"end\",\"type\":\"END\"}]," + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}"; /** USERS 直配变体(Notice 先审后发缩样) */ private static final String USERS_DEF_JSON = "{" + "\"code\":\"notice_audit\",\"name\":\"Notice先审后发\",\"start\":\"start\"," + "\"nodes\":[" + "{\"id\":\"start\",\"type\":\"START\"}," + "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"甲乙会签\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[11,12,13]}}," + "{\"id\":\"end\",\"type\":\"END\"}]," + "\"edges\":[{\"from\":\"start\",\"to\":\"n2\"},{\"from\":\"n2\",\"to\":\"end\"}]}"; private FlwDefRepository defRepository; private FlwAssigneeMapRepository mapRepository; private FlwTaskRepository taskRepository; private OpenApiUserClient userClient; private FlowDiagramService service; @BeforeEach void setUp() { defRepository = mock(FlwDefRepository.class); mapRepository = mock(FlwAssigneeMapRepository.class); taskRepository = mock(FlwTaskRepository.class); userClient = mock(OpenApiUserClient.class); when(taskRepository.findByInstanceIdOrderByIdAsc(anyLong())) .thenReturn(Collections.emptyList()); // 真 FlowEngine(真实 JSON 解析通道)+ mock 依赖 FlowEngine engine = new FlowEngine(new ObjectMapper(), taskRepository, mock(FlwBranchRepository.class), mock(AssigneeResolver.class), mock(ApplicationEventPublisher.class)); service = new FlowDiagramService(defRepository, mapRepository, mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class), taskRepository, engine, userClient); } private FlwDef def(String json) { FlwDef def = new FlwDef(); def.setId(12L); def.setSchId(SCH); def.setFlowGuid("g1"); def.setCode("c1"); def.setName("教师入职"); def.setVersion(2); def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); def.setContentJson(json); return def; } private FlwAssigneeMap mapRow(String deptName, int userId, String userName) { FlwAssigneeMap m = new FlwAssigneeMap(); m.setSchId(SCH); m.setDeptKey("rs"); m.setDeptName(deptName); m.setUserId(userId); m.setUserName(userName); m.setStatus(0); return m; } private BoaUser boaUser(String name) { BoaUser u = mock(BoaUser.class); when(u.getName()).thenReturn(name); return u; } private FlowModels.NodeVo node(FlowModels.DiagramVo vo, String id) { return vo.getNodes().stream().filter(n -> id.equals(n.getId())).findFirst() .orElseThrow(() -> new AssertionError("节点缺失:" + id)); } // ==================== C1:DEPT_KEY 节点科室视图 ==================== @Test void c1_deptView_deptKeyNode_labelDeptName_subLabelCountAndDeadline() { when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList( mapRow("人事处", 11, "张三"), mapRow("人事处", 12, "李四"))); FlowModels.DiagramVo vo = service.build(def(DEPT_DEF_JSON), null, "dept"); FlowModels.NodeVo n1 = node(vo, "n1"); assertEquals("人事处", n1.getLabel()); assertEquals("科室 · 2人 · 时限48h", n1.getSubLabel()); assertEquals("rs", n1.getDeptKey()); assertEquals("DEPT_KEY", n1.getAssigneeType()); assertEquals("ALL", n1.getCountersign()); assertEquals(Integer.valueOf(48), n1.getDeadlineHours()); // 科室视图不出现映射成员姓名 assertFalse(n1.getSubLabel().contains("张三")); assertFalse(n1.getSubLabel().contains("李四")); } @Test void c1_deptView_fallbackChain_deptKeyThenNodeName() { // 映射行全无名 → deptKey 兜底 when(mapRepository.findEnabled(SCH, "rs")) .thenReturn(Collections.singletonList(mapRow("", 11, "张三"))); FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, "dept"), "n1"); assertEquals("rs", n1.getLabel()); assertEquals("科室 · 1人 · 时限48h", n1.getSubLabel()); // 无映射行 → 节点名兜底(人数照实为 0) when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Collections.emptyList()); n1 = node(service.build(def(DEPT_DEF_JSON), null, "dept"), "n1"); assertEquals("人事处审批", n1.getLabel()); assertEquals("科室 · 0人 · 时限48h", n1.getSubLabel()); } @Test void c1_anyCountersign_appendsSuffix() { when(mapRepository.findEnabled(SCH, "rs")) .thenReturn(Collections.singletonList(mapRow("人事处", 11, "张三"))); FlowModels.NodeVo n1 = node(service.build(def(ANY_DEF_JSON), null, "dept"), "n1"); assertEquals("科室 · 1人 · 任一人通过", n1.getSubLabel()); } @Test void c1_fixedLabels_andEdgesCarried() { when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList()); FlowModels.DiagramVo vo = service.build(def(DEPT_DEF_JSON), null, "dept"); assertEquals("开始", node(vo, "start").getLabel()); assertEquals("并行分发", node(vo, "sp1").getLabel()); assertEquals("汇聚等待", node(vo, "j1").getLabel()); assertEquals("结束", node(vo, "end").getLabel()); assertNull(node(vo, "start").getSubLabel()); assertNull(node(vo, "end").getSubLabel()); assertEquals(4, vo.getEdges().size()); assertEquals("start", vo.getEdges().get(0).getFrom()); assertEquals("sp1", vo.getEdges().get(0).getTo()); // 出图元信息(前端"按流程定义 vN 生成"与工具条 Tag 数据源) assertEquals("g1", vo.getFlowGuid()); assertEquals(Long.valueOf(12L), vo.getDefId()); assertEquals("教师入职", vo.getName()); assertEquals(Integer.valueOf(2), vo.getVersion()); assertEquals(Integer.valueOf(1), vo.getStatus()); assertEquals("dept", vo.getMode()); } @Test void c1_autoView_deptKeyNode_rendersDeptStyle() { when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList( mapRow("人事处", 11, "张三"), mapRow("人事处", 12, "李四"))); FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, null), "n1"); assertEquals("人事处", n1.getLabel()); assertEquals("科室 · 2人 · 时限48h", n1.getSubLabel()); assertEquals("auto", service.build(def(DEPT_DEF_JSON), null, null).getMode()); } // ==================== C2:USERS 节点人员视图(UserBase 补名降级) ==================== @Test void c2_userView_usersNode_namesFromUserBase_degradeToId() { BoaUser jia = boaUser("甲老师"); BoaUser yi = boaUser("乙老师"); when(userClient.getUserById("11")).thenReturn(jia); when(userClient.getUserById("12")).thenReturn(yi); when(userClient.getUserById("13")).thenThrow(new RuntimeException("UserBase 不可达")); FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "user"), "n2"); assertEquals("甲乙会签", n2.getLabel()); assertEquals("甲老师、乙老师、#13", n2.getSubLabel()); assertEquals("USERS", n2.getAssigneeType()); assertNull(n2.getDeptKey()); } @Test void c2_userBaseReturnsNullUser_degradesToId() { when(userClient.getUserById(anyString())).thenReturn(null); FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "user"), "n2"); assertEquals("#11、#12、#13", n2.getSubLabel()); } @Test void c2_autoView_usersNode_rendersUserStyle() { BoaUser jia = boaUser("甲老师"); BoaUser yi = boaUser("乙老师"); when(userClient.getUserById("11")).thenReturn(jia); when(userClient.getUserById("12")).thenReturn(yi); when(userClient.getUserById("13")).thenReturn(null); FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "auto"), "n2"); assertEquals("甲老师、乙老师、#13", n2.getSubLabel()); } // ==================== C3:视图分支防泄名单 / DEPT_KEY 人员视图 ==================== @Test void c3_deptView_usersNode_subLabelOnlyAssigneeHint_neverCallsUserBase() { BoaUser jia = boaUser("甲老师"); BoaUser yi = boaUser("乙老师"); when(userClient.getUserById("11")).thenReturn(jia); when(userClient.getUserById("12")).thenReturn(yi); FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "dept"), "n2"); assertEquals("甲乙会签", n2.getLabel()); assertEquals("指定办理人", n2.getSubLabel()); assertFalse(n2.getSubLabel().contains("甲老师")); assertFalse(n2.getSubLabel().contains("乙老师")); // 防泄名单硬保证:科室视图连 UserBase 都不查 verify(userClient, never()).getUserById(anyString()); } @Test void c3_userView_deptKeyNode_listsMemberNames_blankNameDegradesToId() { when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList( mapRow("人事处", 11, "张三"), mapRow("人事处", 12, null))); FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, "user"), "n1"); // 人员视图:label=节点名,subLabel=映射成员名(空名降级 #id)+ 会签/时限后缀 assertEquals("人事处审批", n1.getLabel()); assertEquals("张三、#12 · 时限48h", n1.getSubLabel()); } // ==================== C4:flowGuid 分支取版与参数校验 ==================== @Test void c4_byGuid_returnsHighestPublishedDef() { FlwDef def = def(DEPT_DEF_JSON); when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( SCH, "g1", FlwEnums.DefStatus.PUBLISHED.getCode())) .thenReturn(Optional.of(def)); when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList()); FlowModels.DiagramVo vo = service.byGuid(SCH, "g1", null); assertEquals("教师入职", vo.getName()); assertEquals(Integer.valueOf(2), vo.getVersion()); assertEquals("auto", vo.getMode()); assertNull(vo.getInstanceId()); } @Test void c4_byGuid_noPublished_throwsBizException() { when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( anyLong(), anyString(), anyInt())) .thenReturn(Optional.empty()); BizException e = assertThrows(BizException.class, () -> service.byGuid(SCH, "g1", "dept")); assertEquals("流程不存在或未发布", e.getMessage()); assertEquals(ErrorCode.PARAM_ERROR, e.getCode()); } @Test void c4_byGuid_crossSchoolDef_forbidden() { FlwDef other = def(DEPT_DEF_JSON); other.setSchId(888888L); when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( anyLong(), anyString(), anyInt())) .thenReturn(Optional.of(other)); BizException e = assertThrows(BizException.class, () -> service.byGuid(SCH, "g1", "dept")); assertEquals("跨校不可查看", e.getMessage()); assertEquals(ErrorCode.FORBIDDEN, e.getCode()); } @Test void c4_invalidMode_rejected() { BizException e = assertThrows(BizException.class, () -> service.build(def(DEPT_DEF_JSON), null, "xxx")); assertEquals("mode 仅支持 dept/user", e.getMessage()); } @Test void c4_byInstance_usesDefSnapshotOfInstance() { FlwInstance instance = new FlwInstance(); instance.setId(100L); instance.setSchId(SCH); instance.setDefId(12L); when(defRepository.findById(12L)).thenReturn(Optional.of(def(DEPT_DEF_JSON))); when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList()); FlowModels.DiagramVo vo = service.byInstance(instance, "dept"); assertEquals(Long.valueOf(100L), vo.getInstanceId()); assertEquals(Long.valueOf(12L), vo.getDefId()); assertEquals(Integer.valueOf(2), vo.getVersion()); } @Test void c6_build_draftDef_ok() { FlwDef draft = def(DEPT_DEF_JSON); draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList()); FlowModels.DiagramVo vo = service.build(draft, null, "auto"); assertEquals(Integer.valueOf(0), vo.getStatus()); assertEquals(5, vo.getNodes().size()); } // ==================== C5:用户面 /api/flow/diagram 控制器守卫 ==================== @Nested class UserDiagramEndpoint { private FlowQueryService queryService; private FlowTaskService taskService; private FlowInstanceService instanceService; private FlowUserController controller; private FlowDiagramService diagramService; @BeforeEach void setUpController() { queryService = mock(FlowQueryService.class); taskService = mock(FlowTaskService.class); instanceService = mock(FlowInstanceService.class); diagramService = mock(FlowDiagramService.class); // 真守卫(同 FlowAccessGuardTest 口径):提交人/办理人/本校管理员 FlowAccessGuard accessGuard = new FlowAccessGuard(taskRepository); controller = new FlowUserController(queryService, taskService, instanceService, accessGuard, diagramService, mock(com.yunhai.flowcenter.service.DiagramImageService.class)); } private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) { BoaUser u = mock(BoaUser.class, RETURNS_DEEP_STUBS); when(u.getId()).thenReturn(id); when(u.getType()).thenReturn(type); when(u.getRoleIds()).thenReturn(roleIds.length == 0 ? null : new ArrayList<>(Arrays.asList(roleIds))); when(u.getCurrentRole().getSchId()).thenReturn(schId); return u; } @Test void c5_flowGuid_sameSchoolUser_ok() { FlowModels.DiagramVo vo = new FlowModels.DiagramVo(); when(diagramService.byGuid(SCH, "g1", "dept")).thenReturn(vo); assertSame(vo, controller.diagram(user(11, 2, SCH.intValue(), 9), "g1", null, "dept")); verify(diagramService).byGuid(SCH, "g1", "dept"); } @Test void c5_flowGuid_notLoggedIn_forbidden() { BizException e = assertThrows(BizException.class, () -> controller.diagram(null, "g1", null, null)); assertEquals("未登录", e.getMessage()); assertEquals(ErrorCode.FORBIDDEN, e.getCode()); } @Test void c5_noParam_paramError() { assertThrows(BizException.class, () -> controller.diagram(user(11, 2, SCH.intValue(), 9), null, null, null)); } @Test void c5_instance_submitter_ok() { FlwInstance instance = new FlwInstance(); instance.setId(100L); instance.setSchId(SCH); instance.setSubmitterId(11); when(instanceService.requireInstance(SCH, 100L)).thenReturn(instance); FlowModels.DiagramVo vo = new FlowModels.DiagramVo(); when(diagramService.byInstance(instance, "dept")).thenReturn(vo); assertSame(vo, controller.diagram(user(11, 2, SCH.intValue(), 9), null, 100L, "dept")); verify(diagramService).byInstance(instance, "dept"); } @Test void c5_instance_assignee_ok() { FlwInstance instance = new FlwInstance(); instance.setId(100L); instance.setSchId(SCH); instance.setSubmitterId(11); FlwTask task = new FlwTask(); task.setAssigneeId(22); when(instanceService.requireInstance(SCH, 100L)).thenReturn(instance); when(taskRepository.findByInstanceIdOrderByIdAsc(100L)) .thenReturn(Collections.singletonList(task)); FlowModels.DiagramVo vo = new FlowModels.DiagramVo(); when(diagramService.byInstance(instance, "dept")).thenReturn(vo); assertSame(vo, controller.diagram(user(22, 2, SCH.intValue(), 9), null, 100L, "dept")); } @Test void c5_instance_unrelatedUser_forbidden() { FlwInstance instance = new FlwInstance(); instance.setId(100L); instance.setSchId(SCH); instance.setSubmitterId(11); when(instanceService.requireInstance(SCH, 100L)).thenReturn(instance); BizException e = assertThrows(BizException.class, () -> controller.diagram(user(36, 2, SCH.intValue(), 9), null, 100L, "dept")); assertEquals("仅提交人/办理人/本校管理员可查看该办件", e.getMessage()); verify(diagramService, never()).byInstance(any(), any()); } } // ==================== C6:管理面 /api/flow/admin/def/diagram ==================== @Nested class AdminDefDiagramEndpoint { private FlowAdminController controller; private FlowDefAdminService defAdminService; private FlowDiagramService diagramService; @BeforeEach void setUpController() { defAdminService = mock(FlowDefAdminService.class); diagramService = mock(FlowDiagramService.class); // 真管理守卫(纯逻辑无依赖)+ 其余依赖 mock controller = new FlowAdminController(new FlowAdminGuard(), defAdminService, mock(FlowMapAdminService.class), mock(FlowCallbackAdminService.class), mock(FlwInstanceRepository.class), diagramService); } private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) { BoaUser u = mock(BoaUser.class, RETURNS_DEEP_STUBS); when(u.getId()).thenReturn(id); when(u.getType()).thenReturn(type); when(u.getRoleIds()).thenReturn(roleIds.length == 0 ? null : new ArrayList<>(Arrays.asList(roleIds))); when(u.getCurrentRole().getSchId()).thenReturn(schId); return u; } @Test void c6_admin_draftDef_ok() { FlwDef draft = def(DEPT_DEF_JSON); draft.setId(55L); draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); when(defAdminService.get(SCH, 55L)).thenReturn(draft); FlowModels.DiagramVo vo = new FlowModels.DiagramVo(); when(diagramService.build(draft, null, "auto")).thenReturn(vo); assertSame(vo, controller.defDiagram(user(1, 1, SCH.intValue()), 55L, "auto")); verify(diagramService).build(draft, null, "auto"); } @Test void c6_nonAdmin_forbidden() { BoaUser ordinary = user(77, 2, SCH.intValue(), 9); BizException e = assertThrows(BizException.class, () -> controller.defDiagram(ordinary, 55L, "auto")); assertEquals("无流程管理权限", e.getMessage()); verify(defAdminService, never()).get(any(), anyLong()); } } }