package com.yunhai.flowcenter.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.flowcenter.engine.FlowDefModel; import com.yunhai.flowcenter.model.OapiModels; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.openapi.base.OpenApiUserClient; 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.Mockito; import org.springframework.test.util.ReflectionTestUtils; import java.util.Collections; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** * T06 发起终审规则单测:required 指派全覆盖(空 assigns 不绕过)、禁用账号拒绝、 * EXPRESSION 变量缺失/显式 null 零实例。 */ @DisplayName("发起终审:required 指派覆盖 + 禁用人员 + 变量缺失(T06)") class FlApplyAssignAndVariableTest { private final ObjectMapper mapper = new ObjectMapper(); // ==================== required 覆盖 ==================== @Test @DisplayName("空 assigns + 存在必选指派节点:全部缺失(空 assigns 不绕过 required)") void emptyAssignsWithRequiredNodesIsMissing() throws Exception { FlowDefModel model = FlowDefModel.parse(mapper, defJson(true, false)); List missing = OapiFlowService.missingRequiredAssignNodes(model, null); assertThat(missing).containsExactly("n-assign-1"); assertThat(OapiFlowService.missingRequiredAssignNodes(model, Collections.emptyList())) .containsExactly("n-assign-1"); } @Test @DisplayName("提交项只覆盖部分必选节点:缺失的逐一点名(不只遍历请求里出现的项)") void partialCoverageIsMissing() throws Exception { FlowDefModel model = FlowDefModel.parse(mapper, defJson(true, true)); OapiModels.AssignItem onlyFirst = new OapiModels.AssignItem(); onlyFirst.setNodeId("n-assign-1"); onlyFirst.setUserIds(java.util.Collections.singletonList(101)); List missing = OapiFlowService.missingRequiredAssignNodes(model, java.util.Collections.singletonList(onlyFirst)); assertThat(missing).containsExactly("n-assign-2"); } @Test @DisplayName("无必选节点 + 空 assigns:放行(既有宽松回退 D3 不变)") void noRequiredNodesEmptyAssignsPasses() throws Exception { FlowDefModel model = FlowDefModel.parse(mapper, defJson(false, false)); assertThat(OapiFlowService.missingRequiredAssignNodes(model, null)).isEmpty(); } // ==================== 变量终审 ==================== @Test @DisplayName("EXPRESSION 引用的业务变量缺失:拒绝(零实例,不等运行期 nil)") void missingExpressionVariableRejected() throws Exception { ApplyVariableValidator validator = new ApplyVariableValidator(mapper); FlowDefModel model = modelWithCondition("flowAmount > 100"); assertThat(validator.referencedBusinessKeys(model)).containsExactly("flowAmount"); assertThatThrownBy(() -> validator.requireVariablesPresent(model, "{\"other\":1}")) .isInstanceOf(BizException.class) .hasMessageContaining("flowAmount"); } @Test @DisplayName("变量为显式 null:拒绝(缺失键与 null 是同一种'没有值')") void nullVariableRejected() throws Exception { ApplyVariableValidator validator = new ApplyVariableValidator(mapper); FlowDefModel model = modelWithCondition("flowAmount > 100"); assertThatThrownBy(() -> validator.requireVariablesPresent(model, "{\"flowAmount\":null}")) .isInstanceOf(BizException.class) .hasMessageContaining("flowAmount"); } @Test @DisplayName("变量齐备:放行;flow.* 内置键不参与本查") void presentVariablesPassAndBuiltinsIgnored() throws Exception { ApplyVariableValidator validator = new ApplyVariableValidator(mapper); FlowDefModel model = modelWithCondition("flowAmount > 100 && flow.submitterId != null"); validator.requireVariablesPresent(model, "{\"flowAmount\":200}"); validator.requireVariablesPresent(model, null); } // ==================== 禁用人员终审 ==================== private FlowDefModel contract(String declarations) { return FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[],\"variables\":" + declarations + "}"); } @Test void requiredUnknownAndOptionalNullRules() { ApplyVariableValidator validator = new ApplyVariableValidator(mapper); FlowDefModel m = contract("[{\"key\":\"reason\",\"type\":\"string\",\"required\":true}," + "{\"key\":\"optional\",\"type\":\"number\"}]"); validator.requireVariablesPresent(m, "{\"reason\":\"ok\",\"optional\":null}"); for (String bad : java.util.Arrays.asList("{}", "{\"reason\":null}", "{\"reason\":\"\"}", "{\"reason\":\" \\t\"}", "{\"reason\":\"ok\",\"extra\":1}", "{\"reason\":\"ok\",\"optional\":\"\"}", "[]", "null", "invalid")) { assertThatThrownBy(() -> validator.requireVariablesPresent(m, bad)).isInstanceOf(BizException.class); } validator.requireVariablesPresent(contract("[]"), null); assertThatThrownBy(() -> validator.requireVariablesPresent(contract("[]"), "{\"x\":1}")) .isInstanceOf(BizException.class); validator.requireVariablesPresent(contract("[{\"key\":\"text\",\"type\":\"string\"}]"), "{\"text\":\"\"}"); validator.requireVariablesPresent(contract("[{\"key\":\"date\",\"type\":\"date\"}," + "{\"key\":\"grade\",\"type\":\"string\",\"enum\":[\"g1\"]}]"), "{\"date\":\"\",\"grade\":\"\"}"); } @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.CsvSource(delimiter = '|', value = { "number|12.5|\"12.5\"", "money|0.01|\"0.01\"", "boolean|true|\"true\"", "date|\"2024-02-29\"|\"2025-02-29\"", "datetime|\"2026-10-26T12:30:00\"|\"2026-10-26 12:30:00\"", "datetime|\"2026-10-26T12:30:00+08:00\"|\"2026-10-26T25:30:00Z\"", "string|\"ok\"|123", "stringArray|[\"x\"]|[1]", "fileArray|[{\"id\":1,\"fileName\":\"a.pdf\",\"key\":\"form/a\"}]|[{\"id\":1}]", "userArray|[{\"id\":101,\"name\":\"A\"}]|[101]" }) void realJsonShapesAreCheckedWithoutCoercion(String type, String good, String bad) { ApplyVariableValidator validator = new ApplyVariableValidator(mapper); FlowDefModel model = contract("[{\"key\":\"value\",\"type\":\"" + type + "\",\"required\":true}]"); validator.requireVariablesPresent(model, "{\"value\":" + good + "}"); for (String invalid : java.util.Arrays.asList(bad, "\"\"", "null", "[]")) { assertThatThrownBy(() -> validator.requireVariablesPresent(model, "{\"value\":" + invalid + "}")) .isInstanceOf(BizException.class); } } @Test void optionalReferencedVariableStillNeedsValueAndLegacyKeepsOldBehavior() throws Exception { ApplyVariableValidator validator = new ApplyVariableValidator(mapper); FlowDefModel m = modelWithCondition("flowAmount > 100"); validator.requireVariablesPresent(m, "{\"flowAmount\":\"\",\"extra\":1}"); // 历史行为 m.variables = contract("[{\"key\":\"flowAmount\",\"type\":\"number\",\"required\":false}]").variables; for (String bad : java.util.Arrays.asList("{}", "{\"flowAmount\":null}", "{\"flowAmount\":\"\"}")) { assertThatThrownBy(() -> validator.requireVariablesPresent(m, bad)).isInstanceOf(BizException.class); } validator.requireVariablesPresent(m, "{\"flowAmount\":200}"); } @Test void scalarAndArrayEnumUseFrozenValues() { ApplyVariableValidator validator = new ApplyVariableValidator(mapper); for (String type : java.util.Arrays.asList("string", "stringArray")) { FlowDefModel model = contract("[{\"key\":\"grade\",\"type\":\"" + type + "\",\"enum\":[\"g1\",\"g2\"]}]"); validator.requireVariablesPresent(model, "{\"grade\":" + (type.equals("string") ? "\"g1\"" : "[\"g1\",\"g2\"]") + "}"); assertThatThrownBy(() -> validator.requireVariablesPresent(model, "{\"grade\":" + (type.equals("string") ? "\"g9\"" : "[\"g1\",\"g9\"]") + "}")) .isInstanceOf(BizException.class); } } @Test void v2SameTokenUsesAcceptedDefinitionBeforeCurrentContractAndVersionChecks() { com.yunhai.flowcenter.repository.FlwDefRepository defs = mock(com.yunhai.flowcenter.repository.FlwDefRepository.class); com.yunhai.flowcenter.repository.FlwInstanceRepository instances = mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class); com.yunhai.flowcenter.engine.FlowEngine engine = mock(com.yunhai.flowcenter.engine.FlowEngine.class); FlowInstanceService lifecycle = new FlowInstanceService(instances, defs, mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class), mock(com.yunhai.flowcenter.repository.FlwBranchRepository.class), engine, mock(org.springframework.context.ApplicationEventPublisher.class)); OapiFlowService service = new OapiFlowService(defs, instances, mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class), mock(com.yunhai.flowcenter.repository.FlwCcRepository.class), mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class), lifecycle, mock(FlowTaskService.class), engine, mock(com.yunhai.flowcenter.engine.AssigneeResolver.class), mapper, mock(OpenApiUserClient.class), mock(com.yunhai.platform.openapi.base.OpenApiAppClient.class), mock(DiagramTokenService.class), mock(org.springframework.beans.factory.ObjectProvider.class)); com.yunhai.flowcenter.entity.FlwDef old = new com.yunhai.flowcenter.entity.FlwDef(); old.setId(9L); old.setFlowGuid("g"); old.setVersion(1); old.setContentJson("{\"nodes\":[],\"edges\":[],\"variables\":[]}"); com.yunhai.flowcenter.entity.FlwDef current = new com.yunhai.flowcenter.entity.FlwDef(); current.setId(10L); current.setFlowGuid("g"); current.setVersion(2); current.setContentJson("{\"nodes\":[],\"edges\":[],\"variables\":[{\"key\":\"newRequired\",\"type\":\"string\",\"required\":true}]}"); when(defs.findById(9L)).thenReturn(java.util.Optional.of(old)); when(defs.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(1L,"g",1)).thenReturn(java.util.Optional.of(current)); com.yunhai.flowcenter.entity.FlwInstance row = new com.yunhai.flowcenter.entity.FlwInstance(); row.setId(500L); row.setDefId(9L); row.setSchId(1L); row.setStatus(30); row.setSubmitterId(201); row.setApplyAppKey("app"); row.setApplyRequestToken("token"); row.setApplyRound(1); FlowInstanceService.ApplyCommand original = new FlowInstanceService.ApplyCommand(1L, old, "biz", "7", null, null, null, 201, null, null, "app", null, null); row.setApplyPayloadHash(ReflectionTestUtils.invokeMethod(service, "payloadHash", original)); when(instances.findBySchIdAndBizTypeAndBizId(1L,"biz","7")).thenReturn(java.util.Optional.of(row)); when(instances.findById(500L)).thenReturn(java.util.Optional.of(row)); com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2 request = new com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2(); request.setFlowGuid("g"); request.setBizType("biz"); request.setBizId("7"); request.setSubmitterId(201); request.setRequestToken("token"); request.setExpectedDefVersion(1); assertThat(service.applyV2(1L,"app",request).getBranch()).isEqualTo("SAME_REQUEST"); request.setBizTitle("different payload"); assertThatThrownBy(() -> service.applyV2(1L,"app",request)).isInstanceOf(BizException.class); request.setBizTitle(null); request.setFlowGuid("other"); assertThatThrownBy(() -> service.applyV2(1L,"app",request)).isInstanceOf(BizException.class); Mockito.verify(instances, Mockito.never()).save(Mockito.any()); Mockito.verifyNoInteractions(engine); Mockito.verify(defs, Mockito.never()).findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(Mockito.any(),Mockito.any(),Mockito.any()); // 新请求仍过当前契约;v1/v2 均在任何实例/任务写入之前拒绝,手工构造也不能跳过校验。 request.setFlowGuid("g"); request.setRequestToken("fresh"); request.setExpectedDefVersion(null); assertThatThrownBy(() -> service.applyV2(1L,"app",request)) .isInstanceOf(BizException.class).hasMessageContaining("newRequired"); assertThatThrownBy(() -> service.apply(1L,"app",request)) .isInstanceOf(BizException.class).hasMessageContaining("newRequired"); Mockito.verify(instances, Mockito.never()).save(Mockito.any()); Mockito.verifyNoInteractions(engine); } @Test @DisplayName("禁用账号(status=0):拒绝(存在≠可办理)") void disabledUserRejected() { com.yunhai.flowcenter.engine.AssigneeResolver resolver = new com.yunhai.flowcenter.engine.AssigneeResolver( mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), userClient(0)); assertThatThrownBy(() -> resolver.requireEligibleAssignee(1L, 101)) .isInstanceOf(BizException.class) .hasMessageContaining("禁用"); } @Test @DisplayName("status=null(存量下发未知态):fail-closed 拒绝") void unknownStatusUserRejected() { com.yunhai.flowcenter.engine.AssigneeResolver resolver = new com.yunhai.flowcenter.engine.AssigneeResolver( mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), userClient(null)); assertThatThrownBy(() -> resolver.requireEligibleAssignee(1L, 101)) .isInstanceOf(BizException.class) .hasMessageContaining("禁用"); } @Test @DisplayName("正常账号(status=1)且属于本校:放行") void activeUserPasses() { com.yunhai.flowcenter.engine.AssigneeResolver resolver = new com.yunhai.flowcenter.engine.AssigneeResolver( mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), userClient(1)); assertThat(resolver.requireEligibleAssignee(1L, 101).getName()).isEqualTo("甲"); } @Test @DisplayName("正常账号但属于外校:拒绝(校域终审)") void crossSchoolUserRejected() { OpenApiUserClient client = mock(OpenApiUserClient.class); BoaUser user = new BoaUser(); user.setId(101); user.setName("甲"); user.setStatus(1); user.setSchool(new com.yunhai.platform.utils.model.BoaGroup(2, "外校")); when(client.getUserById(Mockito.anyString())).thenReturn(user); com.yunhai.flowcenter.engine.AssigneeResolver resolver = new com.yunhai.flowcenter.engine.AssigneeResolver( mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), client); assertThatThrownBy(() -> resolver.requireEligibleAssignee(1L, 101)) .isInstanceOf(BizException.class) .hasMessageContaining("不属于该学校"); } // ==================== fixtures ==================== private OpenApiUserClient userClient(Integer status) { OpenApiUserClient client = mock(OpenApiUserClient.class); BoaUser user = new BoaUser(); user.setId(101); user.setName("甲"); user.setStatus(status); user.setSchool(new com.yunhai.platform.utils.model.BoaGroup(1, "测试学校")); when(client.getUserById(Mockito.anyString())).thenReturn(user); return client; } /** 一个 START + required 指派节点(one/two 个)+ END 的最小定义 */ private String defJson(boolean oneRequired, boolean twoRequired) { StringBuilder sb = new StringBuilder(); sb.append("{\"name\":\"t\",\"start\":\"n-start\",\"nodes\":["); sb.append("{\"id\":\"n-start\",\"type\":\"START\",\"name\":\"开始\"},"); sb.append("{\"id\":\"n-assign-1\",\"type\":\"APPROVAL\",\"name\":\"审批一\",") .append("\"assignee\":{\"type\":\"USERS\",\"userIds\":[101]},") .append("\"assignPolicy\":{\"mode\":\"MULTI\",\"required\":").append(oneRequired).append("}},"); if (twoRequired) { sb.append("{\"id\":\"n-assign-2\",\"type\":\"APPROVAL\",\"name\":\"审批二\",") .append("\"assignee\":{\"type\":\"USERS\",\"userIds\":[102]},") .append("\"assignPolicy\":{\"mode\":\"MULTI\",\"required\":true}},"); } sb.append("{\"id\":\"n-end\",\"type\":\"END\",\"name\":\"结束\"}],"); sb.append("\"edges\":[{\"from\":\"n-start\",\"to\":\"n-assign-1\",\"name\":\"提交\"},") .append("{\"from\":\"n-assign-1\",\"to\":\"") .append(twoRequired ? "n-assign-2" : "n-end").append("\",\"name\":\"通过\"}"); if (twoRequired) { sb.append(",{\"from\":\"n-assign-2\",\"to\":\"n-end\",\"name\":\"通过\"}"); } sb.append("]}"); return sb.toString(); } /** START → CONDITION(EXPRESSION) → END,条件含给定表达式 */ private FlowDefModel modelWithCondition(String condition) throws Exception { String json = "{\"name\":\"t\",\"start\":\"n-start\",\"nodes\":[" + "{\"id\":\"n-start\",\"type\":\"START\",\"name\":\"开始\"}," + "{\"id\":\"n-cond\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"}," + "{\"id\":\"n-end\",\"type\":\"END\",\"name\":\"结束\"}]," + "\"edges\":[" + "{\"from\":\"n-start\",\"to\":\"n-cond\",\"name\":\"提交\"}," + "{\"from\":\"n-cond\",\"to\":\"n-end\",\"name\":\"高额\",\"condition\":\"" + condition + "\"}" + "]}"; return FlowDefModel.parse(mapper, json); } }