t1~t2+精修+TASK_4: 定义绑定表单UUID/版本成对核验、发起恢复守卫、schema兼容边界草案
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:
chenxing
2026-10-22 10:03:28 +08:00
parent 60ff464226
commit 4b4d63e3ba
15 changed files with 1510 additions and 6 deletions
@@ -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());
}
}