diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java index da109e3..3837c1a 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java @@ -299,7 +299,7 @@ public class FlowDefAdminService { private void requireLine(Long schId, String flowGuid) { if (flowGuid == null || flowGuid.trim().isEmpty() - || defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim()) + || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim()) .isPresent()) { throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid); } diff --git a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java index 76bbf34..3cff9e9 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java @@ -18,6 +18,9 @@ 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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.ObjectProvider; import org.springframework.test.util.ReflectionTestUtils; @@ -33,7 +36,9 @@ 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.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; /** @@ -53,43 +58,17 @@ class FlDefLineOfflineTest { private static final String GUID = "g-line"; private FlwDefRepository defRepository; - private FlwDefAdminServiceSpy adminService; + private FlowDefAdminService 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); + adminService = newFlowDefAdminService(); instanceService = mock(FlowInstanceService.class); oapiService = new OapiFlowService(defRepository, instances, mock(FlwTaskRepository.class), mock(FlwCcRepository.class), @@ -121,9 +100,61 @@ class FlDefLineOfflineTest { assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope")) .isInstanceOf(BizException.class) .hasMessageContaining("流程线不存在"); + assertThatThrownBy(() -> adminService.enableLine(SCH, "g-nope")) + .isInstanceOf(BizException.class) + .hasMessageContaining("流程线不存在"); + verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t\r\n"}) + void blankGuidRejectedOnBothPaths(String guid) { + assertThatThrownBy(() -> adminService.offlineLine(SCH, guid)) + .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在"); + assertThatThrownBy(() -> adminService.enableLine(SCH, guid)) + .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在"); + verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt()); + verify(defRepository, never()).findFirstBySchIdAndFlowGuidOrderByVersionDesc(any(), any()); + } + + @Test + void otherSchoolCannotChangeExistingLine() { + assertThatThrownBy(() -> adminService.offlineLine(2L, GUID)) + .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在"); + assertThatThrownBy(() -> adminService.enableLine(2L, GUID)) + .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在"); + verify(defRepository, times(2)).findFirstBySchIdAndFlowGuidOrderByVersionDesc(2L, GUID); verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt()); } + @Test + void guidTrimmedForBothLookupAndUpdate() { + when(defRepository.updateLineOffline(SCH, GUID, 1)).thenReturn(3); + when(defRepository.updateLineOffline(SCH, GUID, 0)).thenReturn(3); + assertThat(adminService.offlineLine(SCH, " " + GUID + "\t")).isEqualTo(3); + assertThat(adminService.enableLine(SCH, "\t" + GUID + " ")).isEqualTo(3); + verify(defRepository).updateLineOffline(SCH, GUID, 1); + verify(defRepository).updateLineOffline(SCH, GUID, 0); + } + + @Test + void lineToggleDoesNotChangePublishedDefinitionOrInflightInstances() { + FlwDef published = def(9, 3, 0); + String frozenJson = published.getContentJson(); + when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, GUID)) + .thenReturn(Optional.of(published)); + + adminService.offlineLine(SCH, GUID); + adminService.enableLine(SCH, GUID); + + assertThat(published.getVersion()).isEqualTo(3); + assertThat(published.getStatus()).isEqualTo(FlwEnums.DefStatus.PUBLISHED.getCode()); + assertThat(published.getContentJson()).isEqualTo(frozenJson); + verify(defRepository, never()).save(any()); + verifyNoInteractions(instances, instanceService); + } + // ==================== 新发起/预览的线级守卫 ==================== @Test @@ -229,7 +260,7 @@ class FlDefLineOfflineTest { mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class), mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class), appRepo(true), - mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class), + instances, mock(FlowDeptAdminService.class), mock(FlowEngine.class), new ObjectMapper(), diff --git a/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java new file mode 100644 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java @@ -0,0 +1,122 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.*; +import com.yunhai.flowcenter.entity.*; +import com.yunhai.flowcenter.repository.*; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import javax.persistence.EntityManager; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real service/engine/activation code with repository mocks. Does not prove DB or transaction behavior. */ +class FlNewInstancePathTest { + @Test + void freshInstanceCreatesProvenTaskAndApprovesWhileLineIsOffline() { + ObjectMapper mapper = new ObjectMapper(); + FlwDefRepository defs = mock(FlwDefRepository.class); + FlwInstanceRepository instances = mock(FlwInstanceRepository.class); + FlwTaskRepository tasks = mock(FlwTaskRepository.class); + FlwBranchRepository branches = mock(FlwBranchRepository.class); + FlwAdvanceIntentRepository intents = mock(FlwAdvanceIntentRepository.class); + AssigneeResolver assignees = mock(AssigneeResolver.class); + ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); + List events = new ArrayList<>(); + doAnswer(call -> { events.add(call.getArgument(0)); return null; }) + .when(publisher).publishEvent(any(FlowEvent.class)); + FlowEngine engine = new FlowEngine(mapper, tasks, branches, mock(FlwCcRepository.class), + assignees, mock(ConditionRouteService.class), publisher); + FlowInstanceService apply = new FlowInstanceService(instances, defs, tasks, branches, engine, publisher); + FlowTaskService handle = new FlowTaskService(mapper, tasks, instances, branches, defs, + assignees, engine, publisher); + FlowAdvanceService advance = new FlowAdvanceService(instances, defs, tasks, engine, intents); + ReflectionTestUtils.setField(advance, "entityManager", mock(EntityManager.class)); + FlowNodeAdvanceListener listener = new FlowNodeAdvanceListener(advance, intents); + + FlwDef def = new FlwDef(); + def.setId(7L); def.setSchId(1L); def.setFlowGuid("fresh-line"); + def.setCode("fresh"); def.setVersion(1); def.setStatus(1); def.setLineOffline(0); + def.setContentJson("{\"start\":\"start\",\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\"}," + + "{\"id\":\"review\",\"type\":\"APPROVAL\",\"name\":\"Review\",\"countersign\":\"ALL\"}," + + "{\"id\":\"end\",\"type\":\"END\"}]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"review\"},{\"from\":\"review\",\"to\":\"end\"}]}"); + when(defs.findById(7L)).thenReturn(Optional.of(def)); + when(assignees.resolve(eq(1L), any())).thenReturn(Collections.singletonList(new AssigneeRef(201, "Reviewer"))); + AtomicReference created = new AtomicReference<>(); + when(instances.saveAndFlush(any())).thenAnswer(call -> { + FlwInstance instance = call.getArgument(0); + if (instance.getId() == null) instance.setId(900L); + created.set(instance); return instance; + }); + when(instances.findById(900L)).thenAnswer(call -> Optional.ofNullable(created.get())); + AtomicReference task = new AtomicReference<>(); + when(tasks.save(any())).thenAnswer(call -> { + FlwTask row = call.getArgument(0); row.setId(33L); task.set(row); return row; + }); + when(tasks.findById(33L)).thenAnswer(call -> Optional.ofNullable(task.get())); + when(tasks.findByInstanceIdAndStatus(900L, 0)).thenAnswer(call -> + task.get().getStatus() == 0 ? Collections.singletonList(task.get()) : Collections.emptyList()); + when(tasks.claimAndFinish(eq(33L), eq(1), any(), any(), any())).thenAnswer(call -> { + task.get().setStatus(1); task.get().setEdgeName(call.getArgument(4)); return 1; + }); + AtomicReference activation = new AtomicReference<>(); + when(intents.save(any())).thenAnswer(call -> { + FlwAdvanceIntent row = call.getArgument(0); row.setId(80L); activation.set(row); return row; + }); + when(intents.findByIdForUpdate(80L)).thenAnswer(call -> Optional.of(activation.get())); + when(intents.findByInstanceIdAndNodeIdOrderByIdDesc(900L, "review")) + .thenAnswer(call -> Collections.singletonList(activation.get())); + when(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(900L, "review")) + .thenAnswer(call -> Optional.of(activation.get())); + + FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand( + 1L, def, "test", "fresh-20261027", "Fresh", "/test", "{}", 101, "Applicant", + null, "100001", "notice", null); + FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command, + new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false)); + assertThat(outcome.duplicated).isFalse(); + assertThat(created.get().getCurrentNodes()).isEqualTo("review"); + assertThat(task.get().getStatus()).isZero(); + assertThat(task.get().getAssigneeId()).isEqualTo(201); + FlowEvent taskCreated = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_CREATED) + .findFirst().orElseThrow(AssertionError::new); + // Explicit listener harness: test synchronizations only, no real transaction/AFTER_COMMIT claim. + TransactionSynchronizationManager.initSynchronization(); + try { + listener.persistIntent(taskCreated); + TransactionSynchronizationManager.getSynchronizations().forEach(sync -> sync.beforeCommit(false)); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + assertThat(activation.get().containsTask(task.get().getId())).isTrue(); + assertThat(activation.get().getActivationDefId()).isEqualTo(7L); + assertThat(activation.get().getActivationApplyRound()).isEqualTo(1); + + // Existing tasks resolve their pinned definition even when the line is stopped for new applications. + def.setLineOffline(1); + assertThat(handle.handle(1L, 33L, true, "Approved", 201).getStatus()).isEqualTo(1); + FlowEvent handled = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_HANDLED) + .findFirst().orElseThrow(AssertionError::new); + listener.persistIntent(handled); + listener.onTaskHandled(handled); + assertThat(activation.get().getStatus()).isEqualTo("DONE"); + assertThat(activation.get().getLastError()).isNull(); + assertThat(created.get().getStatus()).isEqualTo(20); + assertThat(created.get().getCurrentNodes()).isNullOrEmpty(); + assertThat(created.get().getDefId()).isEqualTo(7L); + assertThat(created.get().getDefVersion()).isEqualTo(1); + assertThat(events).extracting(FlowEvent::getType) + .contains(FlowEvent.Type.SUBMITTED, FlowEvent.Type.TASK_CREATED, + FlowEvent.Type.TASK_HANDLED, FlowEvent.Type.COMPLETED); + verify(defs, never()).findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(any(), any(), any()); + } +}