C1第一刀:flw_def.form_uuid启用+发布期form-center schema快照嵌入formContract+表达式键查①红牌(audience按数组解析)
build / build-push (push) Failing after 2m6s

This commit is contained in:
chenxing
2026-10-20 22:50:58 +08:00
parent 93ecc79839
commit 97dec39d45
8 changed files with 550 additions and 7 deletions
@@ -0,0 +1,177 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.repository.FlwAppRepository;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* publish() 的 C1 契约测试(2026-10-20 第一刀:发布期快照 + 表达式键查①红牌)。<br>
* 覆盖:① 关联表单(form_uuid 非空)发布成功=content_json 顶层嵌 formContract 且 status=1;
* ② 表达式变量 ∉ 快照字段 ∪ 内置变量 → 红牌阻断(点名节点/出口/变量);③ form_uuid 为空(存量线)
* 不拉快照、行为不变;④ 快照拉取失败=发布阻断(可重试语义)。
*/
class FlowDefAdminServicePublishContractTest {
private static final Long SCH = 27L;
/** 含 CONDITION(EXPRESSION) 节点的合法定义:days/grade 是表单字段、schId 是内置变量 */
private static final String 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 FormCenterSchemaClient schemaClient;
private FlowDefAdminService service;
private final ObjectMapper mapper = new ObjectMapper();
@BeforeEach
void setUp() {
defRepository = mock(FlwDefRepository.class);
schemaClient = mock(FormCenterSchemaClient.class);
service = new FlowDefAdminService(defRepository,
mock(FlwRouterRepository.class),
mock(FlwCallbackRegRepository.class),
mock(FlwAppRepository.class),
mock(FlwInstanceRepository.class),
mock(FlowDeptAdminService.class),
mock(FlowEngine.class),
mapper,
schemaClient);
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
}
private FlwDef draft(String formUuid) {
FlwDef d = new FlwDef();
d.setId(9L);
d.setSchId(SCH);
d.setFlowGuid("g1");
d.setCode("work-leave");
d.setVersion(1);
d.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
d.setAppKey("notice");
d.setContentJson(JSON);
d.setFormUuid(formUuid);
return d;
}
/** 快照:days(number)/grade(select enum g1,g2)/reason(text) */
private Map<String, Object> snapshot() {
Map<String, Object> snap = new LinkedHashMap<>();
snap.put("formUuid", "FU1");
snap.put("formVersion", 3);
snap.put("audience", Arrays.asList("STUDENT", "TEACHER"));
snap.put("fields", Arrays.asList(
field("days", "number", true, null),
field("grade", "select", false, Arrays.asList("g1", "g2")),
field("reason", "text", true, null)));
return snap;
}
private static Map<String, Object> field(String key, String type, boolean required, List<String> enums) {
Map<String, Object> f = new LinkedHashMap<>();
f.put("key", key);
f.put("type", type);
f.put("required", required);
if (enums != null) {
f.put("enum", enums);
}
return f;
}
@Test
void publishEmbedsFormContractSnapshotIntoVersionRow() throws Exception {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
FlwDef published = service.publish(SCH, 9L);
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
JsonNode contract = mapper.readTree(published.getContentJson()).get("formContract");
assertNotNull(contract, "content_json 顶层必须嵌 formContract");
assertEquals("FU1", contract.get("formUuid").asText());
assertEquals(3, contract.get("formVersion").asInt());
assertEquals(3, contract.get("fields").size(), "fields 扁平化嵌入");
assertTrue(contract.get("audience").isArray(), "audience 按数组解析入快照");
}
@Test
void unknownExpressionVariableIsRedCard() {
FlwDef d = draft("FU1");
// 出口条件改成引用快照外变量
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());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage());
assertTrue(ex.getMessage().contains("键查"), "错误文案给可定位的键查提示");
verify(defRepository, never()).save(any());
}
@Test
void builtinVariablesPassKeyCheck() {
// days 与 schId(内置)都合法 → 上面的 happy path 已覆盖;此处单独断言纯内置表达式
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());
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
}
@Test
void legacyDefWithoutFormUuidSkipsSnapshotEntirely() {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft(null)));
FlwDef published = service.publish(SCH, 9L);
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动");
verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
}
@Test
void schemaUnavailableBlocksPublishAsRetryableBizError() {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
when(schemaClient.fetchSchema(SCH, "FU1"))
.thenThrow(new FormCenterSchemaClient.SchemaUnavailableException("timeout", null));
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("快照拉取失败"), "发布阻断文案可辨认:" + ex.getMessage());
verify(defRepository, never()).save(any());
}
}