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): * * */ @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 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; } }