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 第一刀:发布期快照 + 表达式键查①红牌)。
* 覆盖:① 关联表单(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 snapshot() { Map 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 field(String key, String type, boolean required, List enums) { Map 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()); } }