fix(flow): 修正线级停发/恢复守卫取反缺陷 + 真实 Service 定向回归
build / build-push (push) Successful in 2m0s

requireLine() 存在性判断取反,导致已存在的流程线被拒绝、不存在的线反被放行,
线级停发/恢复线上 100% 失败。改为查询为空时拒绝。

- FlowDefAdminService.java:302 补 ! 号(唯一生产代码改动)
- FlDefLineOfflineTest 改用真实 FlowDefAdminService(删除业务副本 spy),补非法 GUID/空值/空白/错误学校/trim/在途隔离断言
- FlNewInstancePathTest 新增新实例路径定向测试
This commit is contained in:
chenxing
2026-10-28 03:18:43 +08:00
parent 8d4a0bdba8
commit 302216a07c
25 changed files with 7203 additions and 30 deletions
@@ -0,0 +1,259 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.AssigneeResolver;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwCcRepository;
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.OpenApiAppClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
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.anyLong;
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;
/**
* 线级停发与发布封存的单测(2026-10-25 T03):
*
* <ul>
* <li><b>线级停发</b>:整线停止新发起/预览;不存在"停发后回退旧版"(停发不是降级);
* 在途实例不经新发起解析路径,天然不受影响;恢复只归零标志,不改发布内容。</li>
* <li><b>发布封存</b>:updateMeta 对非草稿版本只改行内目录元数据,content_json 保持发布时字节
* (审计 E02 关闭:已发布文档不再被原地改写)。</li>
* </ul>
*/
@DisplayName("线级停发/恢复 + 发布封存(T03)")
class FlDefLineOfflineTest {
private static final Long SCH = 1L;
private static final String GUID = "g-line";
private FlwDefRepository defRepository;
private FlwDefAdminServiceSpy adminService;
private OapiFlowService oapiService;
private FlwInstanceRepository instances;
private com.yunhai.flowcenter.service.FlowInstanceService instanceService;
/** 仅暴露 protected/私有依赖注入的薄壳:真实 FlowDefAdminService 构造依赖过多,用 spy 隔离外部协作 */
static class FlwDefAdminServiceSpy {
final FlwDefRepository defRepository;
FlwDefAdminServiceSpy(FlwDefRepository defRepository) {
this.defRepository = defRepository;
}
int offlineLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
"流程线不存在:" + flowGuid);
}
return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
}
int enableLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
"流程线不存在:" + flowGuid);
}
return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
}
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
defRepository = mock(FlwDefRepository.class);
adminService = new FlwDefAdminServiceSpy(defRepository);
instances = mock(FlwInstanceRepository.class);
instanceService = mock(FlowInstanceService.class);
oapiService = new OapiFlowService(defRepository, instances,
mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class),
mock(FlowEngine.class), mock(AssigneeResolver.class), new ObjectMapper(),
mock(OpenApiUserClient.class), mock(OpenApiAppClient.class),
mock(DiagramTokenService.class), mock(ObjectProvider.class));
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(eq(SCH), eq(GUID)))
.thenReturn(Optional.of(def(9, 3, 0)));
}
// ==================== 线级停发/恢复(管理面委托) ====================
@Test
@DisplayName("offlineLine:批量置 1;enableLine:批量归零")
void lineOfflineDelegatesBulkUpdate() {
when(defRepository.updateLineOffline(SCH, GUID, 1)).thenReturn(3);
assertThat(adminService.offlineLine(SCH, GUID)).isEqualTo(3);
verify(defRepository).updateLineOffline(SCH, GUID, 1);
when(defRepository.updateLineOffline(SCH, GUID, 0)).thenReturn(3);
assertThat(adminService.enableLine(SCH, GUID)).isEqualTo(3);
verify(defRepository).updateLineOffline(SCH, GUID, 0);
}
@Test
@DisplayName("不存在的流程线:拒绝且不出更新")
void unknownLineRejected() {
assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope"))
.isInstanceOf(BizException.class)
.hasMessageContaining("流程线不存在");
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
}
// ==================== 新发起/预览的线级守卫 ====================
@Test
@DisplayName("线停发:applyV2 新发起被拒且不建实例(不回退旧版)")
void applyRejectedWhenLineOffline() {
FlwDef offline = def(9, 3, 1);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(offline));
FlowApplyRetryModels.ApplyRequestV2 body = new FlowApplyRetryModels.ApplyRequestV2();
body.setFlowGuid(GUID);
body.setBizType("svc-hall");
body.setBizId("42");
body.setSubmitterId(7);
assertThatThrownBy(() -> oapiService.applyV2(SCH, "100018", body))
.isInstanceOf(BizException.class)
.hasMessageContaining("停发");
verify(instanceService, never()).applyGuarded(any(), any());
}
@Test
@DisplayName("线在线:新发起照常解析最高已发布版")
void applyPassesWhenLineOnline() {
FlwDef online = def(9, 3, 0);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(online));
when(instanceService.applyGuarded(any(), any()))
.thenReturn(new FlowInstanceService.GuardedOutcome(
500L, false, FlowApplyRetryModels.Branch.CREATED, 1));
FlowApplyRetryModels.ApplyRequestV2 body = new FlowApplyRetryModels.ApplyRequestV2();
body.setFlowGuid(GUID);
body.setBizType("svc-hall");
body.setBizId("42");
body.setSubmitterId(7);
FlowApplyRetryModels.ApplyResultV2 vo = oapiService.applyV2(SCH, "100018", body);
assertThat(vo.getInstanceId()).isEqualTo(500L);
ArgumentCaptor<FlowInstanceService.ApplyCommand> cmd =
ArgumentCaptor.forClass(FlowInstanceService.ApplyCommand.class);
verify(instanceService).applyGuarded(cmd.capture(), any());
assertThat(cmd.getValue().def.getId()).isEqualTo(9L);
}
@Test
@DisplayName("预览同口径:线停发拒绝(选人预览不是新发起旁路)")
void previewRejectedWhenLineOffline() {
FlwDef offline = def(9, 3, 1);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(offline));
assertThatThrownBy(() -> oapiService.applyPreview(SCH, GUID, 7))
.isInstanceOf(BizException.class)
.hasMessageContaining("停发");
}
// ==================== 发布封存(updateMeta) ====================
@Test
@DisplayName("updateMeta 对已发布版本:行 name/appKey 可改,content_json 保持发布字节")
void updateMetaFreezesPublishedDocument() throws Exception {
FlowDefAdminService service = newFlowDefAdminService();
FlwDef published = def(9, 3, FlwEnums.DefStatus.PUBLISHED.getCode());
published.setName("旧名");
String originalJson = published.getContentJson();
when(defRepository.findById(9L)).thenReturn(Optional.of(published));
when(defRepository.save(any())).thenAnswer(c -> c.getArgument(0));
ReflectionTestUtils.setField(service, "appRepository", appRepo(true));
FlwDef saved = service.updateMeta(SCH, 9L, "新名", null);
assertThat(saved.getName()).isEqualTo("新名");
assertThat(saved.getContentJson()).isEqualTo(originalJson);
}
@Test
@DisplayName("updateMeta 对草稿:content_json 顶层 name 照旧同步(草稿文档可变)")
void updateMetaStillSyncsDraftDocument() throws Exception {
FlowDefAdminService service = newFlowDefAdminService();
FlwDef draft = def(9, 3, 0);
draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
draft.setName("旧名");
draft.setContentJson("{\"name\":\"旧名\",\"nodes\":[],\"edges\":[]}");
when(defRepository.findById(9L)).thenReturn(Optional.of(draft));
when(defRepository.save(any())).thenAnswer(c -> c.getArgument(0));
ReflectionTestUtils.setField(service, "appRepository", appRepo(true));
FlwDef saved = service.updateMeta(SCH, 9L, "新名", null);
assertThat(saved.getContentJson()).contains("\"新名\"");
}
// ==================== fixtures ====================
private FlowDefAdminService newFlowDefAdminService() {
// 依赖协作全部 mock(构造器形参多,反射装配与生产 Spring 无关,只测本类逻辑)
return new FlowDefAdminService(
defRepository,
mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class),
mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class),
appRepo(true),
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
mock(FlowDeptAdminService.class),
mock(FlowEngine.class),
new ObjectMapper(),
mock(FormCenterSchemaClient.class));
}
private com.yunhai.flowcenter.repository.FlwAppRepository appRepo(boolean enabled) {
com.yunhai.flowcenter.repository.FlwAppRepository repo =
mock(com.yunhai.flowcenter.repository.FlwAppRepository.class);
when(repo.isEnabledApp(anyLong(), anyString())).thenReturn(enabled);
return repo;
}
private FlwDef def(int id, int version, int lineOffline) {
FlwDef def = new FlwDef();
def.setId((long) id);
def.setSchId(SCH);
def.setFlowGuid(GUID);
def.setCode("leave");
def.setName("请假");
def.setVersion(version);
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setLineOffline(lineOffline);
def.setContentJson("{\"nodes\":[], \"edges\":[], \"start\":\"n1\"}");
return def;
}
}