Files
flow-center-service/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java
T

498 lines
23 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}
}
}