Enforce exact published form version contracts

Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
This commit is contained in:
chenxing
2026-10-23 10:51:47 +08:00
parent a10f2e054c
commit 7eeaea55f8
8 changed files with 344 additions and 85 deletions
@@ -4,6 +4,9 @@ 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.enums.FormBindingErrorCode;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
@@ -45,13 +48,12 @@ import static org.mockito.Mockito.when;
* 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点);</li>
* <li><b>发布取确切版本</b>:带版本的关联走 {@code schema?uuid=&version=};
* <b>中心返回的坐标与请求不符即阻断发布</b>,而不是把错的快照嵌进 def 版本行;
* 本列之前就已关联的存量行(formVersion=NULL)继续走"当前发布版"旧口径,行为逐字不变;</li>
* 存量 UUID-only 行发布与启用同样阻断,必须重新绑定明确版本;</li>
* <li><b>目录客户端的调用形态</b>:命名目标 {@code form-center} + 查询里带 schId/page/pageSize,
* 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。</li>
* </ol>
*
* <p>注:{@code FlowDefAdminServicePublishContractTest}/{@code FlowDefAdminServiceSaveTest} 钉的是
* 改造前既有形态(两参 fetchSchema 与 8 参 save),本类只补 v3 新增的那几条,不改它们的断言。</p>
* <p>发布与启用按同一错误码分类;表达式三查测试同时使用明确版本。</p>
*/
@DisplayName("流程定义绑定表单中心 - v3 单测(TASK_1)")
class FlFormBindingTest {
@@ -134,6 +136,7 @@ class FlFormBindingTest {
@DisplayName("成对提交:uuid 与 version 一起落行")
void pairIsSavedTogether() {
when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null)));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON,
null, null, null, "FU1", 3);
@@ -198,7 +201,6 @@ class FlFormBindingTest {
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"));
}
@@ -214,22 +216,112 @@ class FlFormBindingTest {
BizException e = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertEquals(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION.getCode(), e.getErrorCode().getCode());
assertTrue(e.getMessage().contains("核验失败"), e.getMessage());
assertTrue(e.getMessage().contains("发布已阻断"), e.getMessage());
assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
}
@Test
@DisplayName("存量行(本列之前已关联、无版本):继续走当前发布版旧口径")
void legacyRowWithoutVersionKeepsOldBehaviour() {
@ParameterizedTest
@ValueSource(booleans = {false, true})
@DisplayName("缺版本:发布和启用均阻断,不回落当前版本")
void missingVersionBlocksPublishAndEnable(boolean enable) {
FlwDef def = draft("FU1", null);
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot("FU1", 9));
service.publish(SCH, 9L);
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
verify(schemaClient).fetchSchema(SCH, "FU1");
assertEquals(FormBindingErrorCode.VERSION_REQUIRED.getCode(), ex.getErrorCode().getCode());
assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
verify(defRepository, never()).save(any());
}
private FlwDef changeStatus(boolean enable) {
return enable ? service.enable(SCH, 9L) : service.publish(SCH, 9L);
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void invalidVersionBlocksPublishAndEnable(boolean enable) {
FlwDef def = draft("FU1", 0);
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
verify(defRepository, never()).save(any());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void orphanVersionBlocksPublishAndEnable(boolean enable) {
FlwDef def = draft(null, 3);
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
verify(defRepository, never()).save(any());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void schemaFailuresHaveDistinctStableCodesAndDoNotWrite(boolean enable) {
FlwDef def = draft("FU1", 3);
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
RuntimeException[] failures = {
new FormCenterSchemaClient.SchemaUnavailableException("timeout", null),
new FormCenterSchemaClient.SchemaContractViolationException("wrong version"),
new FormCenterSchemaClient.SchemaResourceUnavailableException(null),
new FormCenterSchemaClient.SchemaAccessDeniedException(null)
};
FormBindingErrorCode[] codes = {FormBindingErrorCode.SCHEMA_UNAVAILABLE,
FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION, FormBindingErrorCode.RESOURCE_UNAVAILABLE,
FormBindingErrorCode.ACCESS_DENIED};
for (int i = 0; i < failures.length; i++) {
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenThrow(failures[i]);
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
assertEquals(codes[i].getCode(), ex.getErrorCode().getCode());
assertSame(failures[i], ex.getCause());
assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
assertEquals(FLOW_JSON, def.getContentJson());
org.mockito.Mockito.reset(schemaClient);
}
verify(defRepository, never()).save(any());
}
@Test
void enableUsesExactVersionAndPreservesPublishedSnapshot() {
FlwDef def = draft("FU1", 3);
def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
def.setContentJson(FLOW_JSON.substring(0, FLOW_JSON.length() - 1)
+ ",\"formContract\":{\"formUuid\":\"FU1\",\"formVersion\":3,\"fields\":[]}}");
String frozenJson = def.getContentJson();
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
FlwDef enabled = service.enable(SCH, 9L);
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), enabled.getStatus());
assertEquals(frozenJson, enabled.getContentJson());
verify(schemaClient).fetchSchema(SCH, "FU1", 3);
}
@ParameterizedTest
@ValueSource(strings = {"oops", "3.5", "3.0", "2147483648", "", "true"})
void malformedVersionAtHttpBoundaryHasStableCode(String value) {
FlowAdminGuard guard = mock(FlowAdminGuard.class);
when(guard.requireAdmin(any(), any())).thenReturn(SCH);
com.yunhai.flowcenter.controller.api.FlowAdminController controller =
new com.yunhai.flowcenter.controller.api.FlowAdminController(guard, service,
null, null, null, null, null, null, new ObjectMapper(), null, null, null);
Map<String, Object> body = new LinkedHashMap<>();
body.put("formUuid", "FU1");
body.put("formVersion", value);
BizException ex = assertThrows(BizException.class, () -> controller.defSave(null, body));
assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
verify(defRepository, never()).save(any());
org.mockito.Mockito.verifyNoInteractions(schemaClient);
}
@Test
@@ -240,7 +332,6 @@ class FlFormBindingTest {
service.publish(SCH, 9L);
verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
}
@@ -85,6 +85,7 @@ class FlowDefAdminServicePublishContractTest {
d.setAppKey("notice");
d.setContentJson(JSON);
d.setFormUuid(formUuid);
d.setFormVersion(formUuid == null ? null : 3);
return d;
}
@@ -115,7 +116,7 @@ class FlowDefAdminServicePublishContractTest {
@Test
void publishEmbedsFormContractSnapshotIntoVersionRow() throws Exception {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
FlwDef published = service.publish(SCH, 9L);
@@ -134,7 +135,7 @@ class FlowDefAdminServicePublishContractTest {
// 出口条件改成引用快照外变量
d.setContentJson(JSON.replace("grade == 'g1'", "level == 'high'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage());
@@ -147,7 +148,7 @@ class FlowDefAdminServicePublishContractTest {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "reason > 3"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("reason"),
@@ -159,7 +160,7 @@ class FlowDefAdminServicePublishContractTest {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "days == 'three'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("days"),
@@ -171,7 +172,7 @@ class FlowDefAdminServicePublishContractTest {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "grade == 'g9'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("域查③") && ex.getMessage().contains("g9"),
@@ -184,7 +185,7 @@ class FlowDefAdminServicePublishContractTest {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "days >= 2.5"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
}
@@ -195,7 +196,7 @@ class FlowDefAdminServicePublishContractTest {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "bizType == 'x'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
}
@@ -208,16 +209,18 @@ class FlowDefAdminServicePublishContractTest {
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动");
verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
}
@Test
void schemaUnavailableBlocksPublishAsRetryableBizError() {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
when(schemaClient.fetchSchema(SCH, "FU1"))
when(schemaClient.fetchSchema(SCH, "FU1", 3))
.thenThrow(new FormCenterSchemaClient.SchemaUnavailableException("timeout", null));
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertEquals(com.yunhai.flowcenter.enums.FormBindingErrorCode.SCHEMA_UNAVAILABLE.getCode(),
ex.getErrorCode().getCode());
assertTrue(ex.getMessage().contains("快照拉取失败"), "发布阻断文案可辨认:" + ex.getMessage());
verify(defRepository, never()).save(any());
}
@@ -2,6 +2,12 @@ package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.core.OpenApiException;
import com.yunhai.platform.openapi.core.OpenApiErrorCode;
import com.yunhai.platform.utils.exception.DException;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -72,7 +78,7 @@ class FormCenterSchemaClientTest {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenReturn(schemaData("U1", 3, Arrays.asList("STUDENT", "TEACHER")));
Map<String, Object> snap = client.fetchSchema(27L, "U1");
Map<String, Object> snap = client.fetchSchema(27L, "U1", 3);
assertEquals("U1", snap.get("formUuid"));
assertEquals(3, snap.get("formVersion"));
@@ -88,11 +94,11 @@ class FormCenterSchemaClientTest {
@Test
void legacySingleStringAudienceIsWrappedAsArray() {
Map<String, Object> data = schemaData("U1", 1, null);
Map<String, Object> data = schemaData("U1", 3, null);
data.put("audience", "STUDENT");
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
Map<String, Object> snap = client.fetchSchema(27L, "U1");
Map<String, Object> snap = client.fetchSchema(27L, "U1", 3);
assertEquals(Arrays.asList("STUDENT"), snap.get("audience"),
"C5 切换窗口的旧单值串按一元素数组容错");
@@ -104,7 +110,7 @@ class FormCenterSchemaClientTest {
.thenThrow(new RuntimeException("connect refused"));
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
() -> client.fetchSchema(27L, "U1"));
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
.get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@@ -115,7 +121,77 @@ class FormCenterSchemaClientTest {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(null);
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
() -> client.fetchSchema(27L, "U1"));
assertTrue(true);
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
.get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@ParameterizedTest
@NullSource
@ValueSource(strings = {"0", "-1", "4", "3.5", "wrong", "2147483648"})
void malformedOrWrongReturnedVersionIsContractFailureWithoutRetry(String returnedVersion) {
Map<String, Object> data = schemaData("U1", 3, Arrays.asList("STUDENT"));
data.put("version", returnedVersion);
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@ParameterizedTest
@NullSource
@ValueSource(strings = {"", "U2"})
void missingOrWrongReturnedUuidIsContractFailureWithoutRetry(String returnedUuid) {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenReturn(schemaData(returnedUuid, 3, Arrays.asList("STUDENT")));
assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@ParameterizedTest
@ValueSource(ints = {0, -1})
void illegalRequestedVersionMakesNoRemoteCall(int version) {
assertThrows(IllegalArgumentException.class, () -> client.fetchSchema(27L, "U1", version));
org.mockito.Mockito.verifyNoInteractions(openCall);
}
@Test
void resourceRejectionUsesEnvelopeCodeAndDoesNotRetry() {
// SDK 实际携带 OpenApiErrorCode,不会转换成本地 ErrorCode 枚举;文案刻意无关键词。
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new DException(new OpenApiErrorCode(2, "opaque")));
assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@ParameterizedTest
@ValueSource(ints = {3, 4})
void authRejectionDoesNotBlindlyRetry(int code) {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new DException(new OpenApiErrorCode(code, "opaque")));
assertThrows(FormCenterSchemaClient.SchemaAccessDeniedException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@Test
void http404DoesNotBlindlyRetry() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new OpenApiException("opaque", 404, ""));
assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@Test
void internalRemoteFailureRemainsRetryable() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new DException(new OpenApiErrorCode(6, "opaque")));
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
.get(anyString(), anyString(), anyMap(), eq(Map.class));
}
}