Files
flow-center-service/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java
T

225 lines
10 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 numericCompareOnTextFieldIsRedCardTypeBucket() {
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());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("reason"),
"text 字段做数值比较要点名(类型桶查②):" + ex.getMessage());
}
@Test
void stringLiteralEqualsOnNumberFieldIsRedCardTypeBucket() {
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());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("days"),
"number 字段与字符串字面量等值要拦:" + ex.getMessage());
}
@Test
void enumLiteralOutOfDomainIsRedCard() {
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());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("域查③") && ex.getMessage().contains("g9"),
"枚举字面量不在值域要点名(域查③):" + ex.getMessage());
}
@Test
void numericFieldWithNumericLiteralPassesAllChecks() {
// days(number) > 3 与内置 schId > 0:happy path(publishEmbeds…已覆盖主链),此处锁数值正向桶
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());
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
}
@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());
}
}