t1~t2+精修+TASK_4: 定义绑定表单UUID/版本成对核验、发起恢复守卫、schema兼容边界草案
build / build-push (push) Successful in 1m10s
build / build-push (push) Successful in 1m10s
含10-22精修: 保存/发布/启用按确切uuid+version远端核验、升版继承两列、取消关联清空版本、缺版本不回落最新。TASK_4=docs/schema-extension-compatibility-next-tasks.md(纯文档)。
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
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.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 代发起幂等守卫与恢复分支的单测(answer-delivery-v2 §8,TASK_2 C.2/C.3)。
|
||||
*
|
||||
* <p>要证的正是任务书点名的那一类事故:<b>消费方响应丢失后按同业务键补发,绝不能被中心
|
||||
* 当成"用户又重提了一次"</b>。旧形状下 biz uk 命中 30/40 就走 R4 重启推进,把已拒绝/已撤回
|
||||
* 的办件重新跑起来;新分支用 {@code requestToken}/{@code attempt}/{@code retryOnly}
|
||||
* 把"同一次命令的重投"与"新一轮授权重提"分成两个事实。</p>
|
||||
*
|
||||
* <p>同时钉住三条边界:</p>
|
||||
* <ul>
|
||||
* <li>并发唯一键冲突在<b>事务边界外</b>重读,且先核调用应用/提交者/流程线——
|
||||
* 别的应用相同 bizType 键<b>不作为自己的结果</b>返回;</li>
|
||||
* <li>存量行({@code apply_round} 为 NULL)按第 1 轮处理,<b>守卫缺省是关</b>:
|
||||
* 没带更高轮次就重启不了别人的老件;</li>
|
||||
* <li>既有 R4 重提语义与流程节点算法一行未改({@code RESTARTED} 分支才走 restart)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@DisplayName("代发起请求标识与轮次守卫 - FL 侧规则单测(TASK_2)")
|
||||
class FlApplyRecoveryTest {
|
||||
|
||||
private static final long SCH = 1L;
|
||||
private static final String BIZ_TYPE = "svc-hall";
|
||||
private static final String BIZ_ID = "42";
|
||||
private static final String FLOW_GUID = "fg-1";
|
||||
private static final String TOKEN_A = "tok-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private static final String TOKEN_B = "tok-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private FlwInstanceRepository instanceRepository;
|
||||
private FlwDefRepository defRepository;
|
||||
private FlwTaskRepository taskRepository;
|
||||
private FlwBranchRepository branchRepository;
|
||||
private FlowEngine flowEngine;
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
private FlowInstanceService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
instanceRepository = mock(FlwInstanceRepository.class);
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
taskRepository = mock(FlwTaskRepository.class);
|
||||
branchRepository = mock(FlwBranchRepository.class);
|
||||
flowEngine = mock(FlowEngine.class);
|
||||
eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
service = new FlowInstanceService(instanceRepository, defRepository, taskRepository,
|
||||
branchRepository, flowEngine, eventPublisher);
|
||||
|
||||
FlwDef def = def(1L, FLOW_GUID, 3);
|
||||
when(defRepository.findById(anyLong())).thenReturn(Optional.of(def));
|
||||
FlowDefModel model = FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[]}");
|
||||
when(flowEngine.parseModel(any())).thenReturn(model);
|
||||
when(instanceRepository.saveAndFlush(any())).thenAnswer(call -> {
|
||||
FlwInstance row = call.getArgument(0);
|
||||
row.setId(900L);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 同一次命令的重投 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("同 requestToken 命中被拒(30)的办件:只回读现状,绝不重启推进")
|
||||
void sameTokenOnRejectedInstanceDoesNotRestart() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
|
||||
|
||||
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
|
||||
assertThat(outcome.duplicated).isTrue();
|
||||
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
verify(branchRepository, never()).deleteByInstance(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同 requestToken 命中在途(10):同一条判定优先于幂等分支,回 SAME_REQUEST")
|
||||
void sameTokenOnApprovingInstanceIsSameRequest() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("retryOnly 命中终态(40):恢复链补发也不重启(C.2 点名不把 30/40 当网络重试)")
|
||||
void retryOnlyNeverRestartsTerminal() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.WITHDRAWN.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, true));
|
||||
|
||||
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.REPLAY_ONLY);
|
||||
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.WITHDRAWN.getCode());
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("10/20 命中不同 token:仍是纯幂等返回(既有语义逐字不变)")
|
||||
void approvingOrCompletedStayIdempotent() {
|
||||
FlwInstance approving = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(approving));
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT);
|
||||
|
||||
FlwInstance completed = instance(900L, FlwEnums.InstanceStatus.COMPLETED.getCode(), TOKEN_A, 1, "100018");
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT);
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
// ==================== 轮次守卫 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("更高轮次 + 新 token 命中 30:才是新一轮授权重提,走既有 R4 重启并推进")
|
||||
void higherRoundRestartsPerExistingR4Semantics() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false));
|
||||
|
||||
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.RESTARTED);
|
||||
assertThat(outcome.applyRound).isEqualTo(2);
|
||||
assertThat(existing.getApplyRequestToken()).isEqualTo(TOKEN_B);
|
||||
assertThat(existing.getApplyRound()).isEqualTo(2);
|
||||
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
verify(flowEngine).startAdvance(any(), any());
|
||||
verify(branchRepository).deleteByInstance(900L);
|
||||
verify(taskRepository).invalidatePendingByInstance(900L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("轮次不高于行上轮次:陈旧轮次迟到重投只回读,不重启")
|
||||
void staleRoundIsRejectedForRestart() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.STALE_ROUND);
|
||||
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("存量行(apply_round 为 NULL)按第 1 轮处理:attempt=1 重投重启不了它")
|
||||
void legacyRowWithoutRoundDefaultsToGuarded() {
|
||||
FlwInstance legacy = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(legacy));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.STALE_ROUND);
|
||||
assertThat(legacy.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.RESTARTED);
|
||||
}
|
||||
|
||||
// ==================== 归属复核 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("别的应用同 bizType+bizId:拒绝,不把别人的办件 id 当自己的结果")
|
||||
void otherApplicationSameBizKeyIsRefused() {
|
||||
FlwInstance foreign = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100020");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(foreign));
|
||||
|
||||
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("另一调用应用");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交者不符 / 流程线不同:同样拒绝(守卫不是可协商的提示)")
|
||||
void submitterAndFlowLineMustMatch() {
|
||||
FlwInstance otherSubmitter = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
TOKEN_A, 1, "100018");
|
||||
otherSubmitter.setSubmitterId(2002);
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(otherSubmitter));
|
||||
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("提交者");
|
||||
|
||||
FlwInstance otherFlow = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(otherFlow));
|
||||
when(defRepository.findById(anyLong())).thenReturn(Optional.of(def(2L, "fg-other", 1)));
|
||||
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("流程线");
|
||||
}
|
||||
|
||||
// ==================== 新建与并发 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("无既有实例:新建并落 token/轮次,走 START 推进(守卫不影响首发路径)")
|
||||
void createsWhenAbsentAndStampsIdentity() {
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.<FlwInstance>empty());
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
|
||||
|
||||
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.CREATED);
|
||||
assertThat(outcome.duplicated).isFalse();
|
||||
ArgumentCaptor<FlwInstance> saved = ArgumentCaptor.forClass(FlwInstance.class);
|
||||
verify(instanceRepository).saveAndFlush(saved.capture());
|
||||
assertThat(saved.getValue().getApplyRequestToken()).isEqualTo(TOKEN_A);
|
||||
assertThat(saved.getValue().getApplyRound()).isEqualTo(1);
|
||||
assertThat(saved.getValue().getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
verify(flowEngine).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("并发唯一键撞键:applyV2 在事务外重读既有行并核归属,返回稳定分支码而不是 500")
|
||||
void concurrentUkCollisionIsResolvedOutsideTransaction() {
|
||||
// 撞键路径由 OapiFlowService 在事务边界外收口;这里证服务层两步读到的结果自洽
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.<FlwInstance>empty())
|
||||
.thenReturn(Optional.of(existing));
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.CREATED);
|
||||
|
||||
// 第二次同 token:读到了并发对手刚建的行 → SAME_REQUEST(不重启、不重复建)
|
||||
FlowInstanceService.GuardedOutcome second =
|
||||
service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
|
||||
assertThat(second.branch).isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
|
||||
assertThat(second.instanceId).isEqualTo(900L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("旧 apply(cmd) 路径逐字不变:不写 token/轮次,30/40 仍按 R4 重启(存量兼容)")
|
||||
void legacyApplyPathIsUnchanged() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.ApplyOutcome outcome = service.apply(cmd(null));
|
||||
|
||||
assertThat(outcome.isDuplicated()).isTrue();
|
||||
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
assertThat(existing.getApplyRequestToken()).isNull();
|
||||
assertThat(existing.getApplyRound()).isNull();
|
||||
}
|
||||
|
||||
// ==================== 夹具 ====================
|
||||
|
||||
/** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */
|
||||
private FlowInstanceService.ApplyCommand cmd(String token) {
|
||||
return new FlowInstanceService.ApplyCommand(SCH, def(1L, FLOW_GUID, 3), BIZ_TYPE, BIZ_ID,
|
||||
"张三的测试事项", "/svc-hall/apply-detail?id=42", "{}", 1001,
|
||||
"张三", "{}", "100018", "svc-hall", null);
|
||||
}
|
||||
|
||||
private FlowInstanceService.ApplyRetryPolicy policy(String token, Integer attempt, boolean retryOnly) {
|
||||
return new FlowInstanceService.ApplyRetryPolicy(token, attempt, retryOnly);
|
||||
}
|
||||
|
||||
private FlwDef def(Long id, String flowGuid, int version) {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setId(id);
|
||||
def.setSchId(SCH);
|
||||
def.setFlowGuid(flowGuid);
|
||||
def.setCode("leave");
|
||||
def.setName("请假");
|
||||
def.setVersion(version);
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setContentJson("{\"nodes\":[],\"edges\":[]}");
|
||||
return def;
|
||||
}
|
||||
|
||||
private FlwInstance instance(Long id, int status, String token, Integer round, String appKey) {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(id);
|
||||
instance.setSchId(SCH);
|
||||
instance.setDefId(1L);
|
||||
instance.setDefCode("leave");
|
||||
instance.setDefVersion(3);
|
||||
instance.setBizType(BIZ_TYPE);
|
||||
instance.setBizId(BIZ_ID);
|
||||
instance.setStatus(status);
|
||||
instance.setApplyAppKey(appKey);
|
||||
instance.setApplyRequestToken(token);
|
||||
instance.setApplyRound(round);
|
||||
instance.setSubmitterId(1001);
|
||||
instance.setSubmitterName("张三");
|
||||
instance.setVersion(0);
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwRouterRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 流程定义的表单中心绑定单测(v3/TASK_1)。
|
||||
*
|
||||
* <p>三组判据:</p>
|
||||
* <ol>
|
||||
* <li><b>成对</b>:{@code formUuid} 与 {@code formVersion} 同传或都不传;只传一个即拒;
|
||||
* 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点);</li>
|
||||
* <li><b>发布取确切版本</b>:带版本的关联走 {@code schema?uuid=&version=};
|
||||
* <b>中心返回的坐标与请求不符即阻断发布</b>,而不是把错的快照嵌进 def 版本行;
|
||||
* 本列之前就已关联的存量行(formVersion=NULL)继续走"当前发布版"旧口径,行为逐字不变;</li>
|
||||
* <li><b>目录客户端的调用形态</b>:命名目标 {@code form-center} + 查询里带 schId/page/pageSize,
|
||||
* 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注:{@code FlowDefAdminServicePublishContractTest}/{@code FlowDefAdminServiceSaveTest} 钉的是
|
||||
* 改造前既有形态(两参 fetchSchema 与 8 参 save),本类只补 v3 新增的那几条,不改它们的断言。</p>
|
||||
*/
|
||||
@DisplayName("流程定义绑定表单中心 - v3 单测(TASK_1)")
|
||||
class FlFormBindingTest {
|
||||
|
||||
private static final Long SCH = 27L;
|
||||
private static final String SIMPLE_JSON = "{\"nodes\":[],\"edges\":[]}";
|
||||
|
||||
/** 含 EXPRESSION 判断的合法定义:days/grade 是表单字段,schId 是内置变量(与发布契约测试同形) */
|
||||
private static final String FLOW_JSON = "{\"start\":\"s\",\"nodes\":["
|
||||
+ "{\"id\":\"s\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"c\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
|
||||
+ "{\"id\":\"a\",\"type\":\"APPROVAL\",\"name\":\"审1\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[1]}},"
|
||||
+ "{\"id\":\"e\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":["
|
||||
+ "{\"from\":\"s\",\"to\":\"c\"},"
|
||||
+ "{\"from\":\"c\",\"to\":\"a\",\"name\":\"准\",\"condition\":\"days > 3 && schId > 0\"},"
|
||||
+ "{\"from\":\"c\",\"to\":\"e\",\"name\":\"否\",\"condition\":\"grade == 'g1'\"},"
|
||||
+ "{\"from\":\"a\",\"to\":\"e\"}]}";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
private com.yunhai.flowcenter.repository.FlwAppRepository appRepository;
|
||||
private FormCenterSchemaClient schemaClient;
|
||||
private FlowDefAdminService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
appRepository = mock(com.yunhai.flowcenter.repository.FlwAppRepository.class);
|
||||
schemaClient = mock(FormCenterSchemaClient.class);
|
||||
service = new FlowDefAdminService(defRepository,
|
||||
mock(FlwRouterRepository.class),
|
||||
mock(FlwCallbackRegRepository.class),
|
||||
appRepository,
|
||||
mock(FlwInstanceRepository.class),
|
||||
mock(FlowDeptAdminService.class),
|
||||
mock(FlowEngine.class),
|
||||
new ObjectMapper(),
|
||||
schemaClient);
|
||||
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
// 归属应用白名单:一律放行(拦截语义由既有专门用例覆盖,本类只管表单坐标)
|
||||
when(appRepository.isEnabledApp(any(), any())).thenReturn(true);
|
||||
}
|
||||
|
||||
private static FlwDef draft(String formUuid, Integer formVersion) {
|
||||
FlwDef d = new FlwDef();
|
||||
d.setId(9L);
|
||||
d.setSchId(SCH);
|
||||
d.setFlowGuid("G");
|
||||
d.setCode("work-leave");
|
||||
d.setVersion(1);
|
||||
d.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
d.setAppKey("notice");
|
||||
d.setContentJson(FLOW_JSON);
|
||||
d.setFormUuid(formUuid);
|
||||
d.setFormVersion(formVersion);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** 快照(formUuid=FU1 / v3),字段够表达式键查用 */
|
||||
private static Map<String, Object> snapshot(String uuid, int version) {
|
||||
Map<String, Object> snap = new LinkedHashMap<>();
|
||||
snap.put("formUuid", uuid);
|
||||
snap.put("formVersion", version);
|
||||
snap.put("audience", Arrays.asList("STUDENT"));
|
||||
snap.put("fields", Arrays.asList(field("days", "number"), field("grade", "select")));
|
||||
return snap;
|
||||
}
|
||||
|
||||
private static Map<String, Object> field(String key, String type) {
|
||||
Map<String, Object> f = new LinkedHashMap<>();
|
||||
f.put("key", key);
|
||||
f.put("type", type);
|
||||
f.put("required", Boolean.FALSE);
|
||||
return f;
|
||||
}
|
||||
|
||||
// ==================== ① 成对规则 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("成对提交:uuid 与 version 一起落行")
|
||||
void pairIsSavedTogether() {
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null)));
|
||||
|
||||
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON,
|
||||
null, null, null, "FU1", 3);
|
||||
|
||||
assertEquals("FU1", saved.getFormUuid());
|
||||
assertEquals(Integer.valueOf(3), saved.getFormVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("只传 uuid 或只传 version:拒,且不落库")
|
||||
void halfCoordinateIsRejected() {
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null)));
|
||||
|
||||
BizException uuidOnly = assertThrows(BizException.class, () -> service.save(SCH, 9L,
|
||||
"work-leave", "请假", SIMPLE_JSON, null, null, null, "FU1", null));
|
||||
assertTrue(uuidOnly.getMessage().contains("必须成对提交"), uuidOnly.getMessage());
|
||||
|
||||
BizException versionOnly = assertThrows(BizException.class, () -> service.save(SCH, 9L,
|
||||
"work-leave", "请假", SIMPLE_JSON, null, null, null, null, 4));
|
||||
assertTrue(versionOnly.getMessage().contains("必须成对提交"), versionOnly.getMessage());
|
||||
|
||||
// 半个坐标在保存分派之前就被拦下:这一行一次都没写库
|
||||
verify(defRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("取消关联:空串 uuid 同时清空两列(哪怕请求里还带着版本号)")
|
||||
void unlinkClearsBothColumns() {
|
||||
FlwDef existing = draft("FU1", 3);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(existing));
|
||||
|
||||
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON,
|
||||
null, null, null, " ", 7);
|
||||
|
||||
assertSame(existing, saved);
|
||||
assertNull(saved.getFormUuid());
|
||||
assertNull(saved.getFormVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("都不传=保持原关联不变(兼容旧调用面,缺省语义不能变成取消)")
|
||||
void nullMeansKeep() {
|
||||
FlwDef existing = draft("FU1", 3);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(existing));
|
||||
|
||||
FlwDef saved = service.save(SCH, 9L, "work-leave", "改名", SIMPLE_JSON,
|
||||
null, null, null, null, null);
|
||||
|
||||
assertEquals("FU1", saved.getFormUuid());
|
||||
assertEquals(Integer.valueOf(3), saved.getFormVersion());
|
||||
}
|
||||
|
||||
// ==================== ② 发布期确切版本 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("带确切版本发布:按 uuid+version 取快照,并把版本坐标嵌进 def 行")
|
||||
void publishUsesExactVersion() {
|
||||
FlwDef def = draft("FU1", 3);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
|
||||
|
||||
FlwDef published = service.publish(SCH, 9L);
|
||||
|
||||
verify(schemaClient).fetchSchema(SCH, "FU1", 3);
|
||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
|
||||
assertTrue(published.getContentJson().contains("\"formContract\""), published.getContentJson());
|
||||
assertTrue(published.getContentJson().contains("FU1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("坐标不符:发布阻断(错误文案点名核验失败,不是拉取失败)")
|
||||
void publishBlockedOnCoordinateMismatch() {
|
||||
FlwDef def = draft("FU1", 3);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3))
|
||||
.thenThrow(new FormCenterSchemaClient.SchemaContractViolationException(
|
||||
"表单中心返回的坐标与请求不符(请求 FU1 v3,返回 FU1 v4)"));
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||
|
||||
assertTrue(e.getMessage().contains("核验失败"), e.getMessage());
|
||||
assertTrue(e.getMessage().contains("发布已阻断"), e.getMessage());
|
||||
assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("存量行(本列之前已关联、无版本):继续走当前发布版旧口径")
|
||||
void legacyRowWithoutVersionKeepsOldBehaviour() {
|
||||
FlwDef def = draft("FU1", null);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot("FU1", 9));
|
||||
|
||||
service.publish(SCH, 9L);
|
||||
|
||||
verify(schemaClient).fetchSchema(SCH, "FU1");
|
||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未关联表单的流程:一次都不碰表单中心")
|
||||
void noBindingNoCall() {
|
||||
FlwDef def = draft(null, null);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
|
||||
service.publish(SCH, 9L);
|
||||
|
||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
|
||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
// ==================== ③ 客户端形态 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("精确版本请求:query 带 version;返回坐标不符即 SchemaContractViolationException")
|
||||
void schemaClientSendsVersionAndChecksCoordinate() {
|
||||
YschOpenCall openCall = mock(YschOpenCall.class);
|
||||
FormCenterSchemaClient client = new FormCenterSchemaClient(openCall, new ObjectMapper());
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("formUuid", "FU1");
|
||||
data.put("version", 3);
|
||||
data.put("code", "leave-form");
|
||||
data.put("appCode", "svc-hall");
|
||||
data.put("audience", Collections.singletonList("STUDENT"));
|
||||
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||
when(openCall.get(eq("form-center"), eq("/oapi/form/schema"), any(), eq(Map.class))).thenReturn(data);
|
||||
|
||||
Map<String, Object> snap = client.fetchSchema(SCH, "FU1", 3);
|
||||
assertEquals("FU1", snap.get("formUuid"));
|
||||
assertEquals(3, snap.get("formVersion"));
|
||||
|
||||
verify(openCall).get(eq("form-center"), eq("/oapi/form/schema"), query.capture(), eq(Map.class));
|
||||
assertEquals("3", query.getValue().get("version"));
|
||||
assertEquals("FU1", query.getValue().get("uuid"));
|
||||
assertEquals(String.valueOf(SCH), query.getValue().get("schId"));
|
||||
|
||||
data.put("version", 4);
|
||||
assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
|
||||
() -> client.fetchSchema(SCH, "FU1", 3));
|
||||
// 坐标不符不重试:两次调用=成功一次 + 不符一次;若按可重试口径处理会变成三次
|
||||
verify(openCall, org.mockito.Mockito.times(2))
|
||||
.get(eq("form-center"), eq("/oapi/form/schema"), any(), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("精确版本形态不给 version:直接拒(缺版本=旧口径,不许混着调)")
|
||||
void schemaClientRequiresExplicitVersion() {
|
||||
FormCenterSchemaClient client = new FormCenterSchemaClient(mock(YschOpenCall.class), new ObjectMapper());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> client.fetchSchema(SCH, "FU1", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("目录客户端:命名目标 form-center,query 带 CAS 校域与分页;空 data 报不可用")
|
||||
void catalogClientQueryShapeAndEmptyData() {
|
||||
YschOpenCall openCall = mock(YschOpenCall.class);
|
||||
FormCenterCatalogClient client = new FormCenterCatalogClient(openCall);
|
||||
FormCenterCatalogClient.CatalogPage page = new FormCenterCatalogClient.CatalogPage();
|
||||
FormCenterCatalogClient.CatalogItem item = new FormCenterCatalogClient.CatalogItem();
|
||||
item.setFormUuid("FU1");
|
||||
item.setVersion(3);
|
||||
item.setName("请假单");
|
||||
page.setItems(Arrays.asList(item));
|
||||
page.setTotal(1);
|
||||
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
|
||||
eq(FormCenterCatalogClient.CatalogPage.class))).thenReturn(page);
|
||||
|
||||
FormCenterCatalogClient.CatalogPage got = client.listForms(SCH, "svc-hall", "请假", 2, 20);
|
||||
|
||||
assertEquals(1, got.getItems().size());
|
||||
assertEquals("FU1", got.getItems().get(0).getFormUuid());
|
||||
verify(openCall).get(eq("form-center"), eq("/oapi/form/forms"), query.capture(),
|
||||
eq(FormCenterCatalogClient.CatalogPage.class));
|
||||
assertEquals(String.valueOf(SCH), query.getValue().get("schId"));
|
||||
assertEquals("svc-hall", query.getValue().get("appCode"));
|
||||
assertEquals("请假", query.getValue().get("keyword"));
|
||||
assertEquals("2", query.getValue().get("page"));
|
||||
assertEquals("20", query.getValue().get("pageSize"));
|
||||
|
||||
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
|
||||
eq(FormCenterCatalogClient.CatalogPage.class))).thenReturn(null);
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> client.listForms(SCH, null, null, null, null));
|
||||
assertTrue(e.getMessage().contains("空"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("目录不可达(网络/未授权)转可诊断 BizException,不静默给空列表")
|
||||
void catalogUnreachableIsSurfaced() {
|
||||
YschOpenCall openCall = mock(YschOpenCall.class);
|
||||
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
|
||||
eq(FormCenterCatalogClient.CatalogPage.class))).thenThrow(new IllegalStateException("connect refused"));
|
||||
FormCenterCatalogClient client = new FormCenterCatalogClient(openCall);
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> client.listForms(SCH, null, null, 1, 20));
|
||||
|
||||
assertTrue(e.getMessage().contains("不可达"), e.getMessage());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user