Files
svc-hall-service/src/test/java/com/yunhai/svchall/service/impl/FlowCenterClientFacadeTest.java
T
chenxing a4d220362e Preserve active leases and align flow recovery rounds
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.
2026-10-23 10:51:40 +08:00

313 lines
17 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.svchall.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.flow.YschFlowOpenApi;
import com.yunhai.platform.openapi.flow.impl.DefaultOpenApiFlowClient;
import com.yunhai.platform.openapi.flow.model.FlowApplyBody;
import com.yunhai.platform.openapi.flow.model.FlowCallbackRegBody;
import com.yunhai.svchall.model.svc.SvcModels;
import com.yunhai.svchall.service.FlowClient;
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.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
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.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* svc-hall → flow-center 出站契约测试:服务目录显式配置 {@code flowcenter} 时,
* <b>全部</b>出站(listFlows / getFlow / apply / instance / callback·reg)必须走 SDK 命名目标
* (门面 {@link YschFlowOpenApi} 或预绑 {@code BoundOpenCall}),不再回落应用专属
* {@code svchall.flow.base-url}(《子应用配置优化方案》§4.2 零专属地址配置)。<br><br>
* 此前只有 listFlows/getFlow 走门面,apply/instance/registerCallback 仍用 ad-hoc
* {@code TargetSpec.of(baseUrl,"")}——地址仍来自应用配置,且 {@code hasTarget} 探测与真实调用
* 分支不一致(探测说"目录已配",调用却打 legacy 地址)。
*/
@DisplayName("svc-hall FlowCenterClient - 服务目录命名目标(全端点)")
class FlowCenterClientFacadeTest {
private static final String TARGET = "flow-center";
private static final String LEGACY_BASE = "http://flow-center-service:10008/flow-center";
private final ObjectMapper objectMapper = new ObjectMapper();
private YschOpenCall openCall;
@BeforeEach
void setUp() {
openCall = mock(YschOpenCall.class);
when(openCall.hasTarget(TARGET)).thenReturn(true);
// 门面静态入口:setCall 由被测类构造器完成,setClient 由 starter 完成——单测手工对齐装配
YschFlowOpenApi.setCall(openCall);
YschFlowOpenApi.setClient(new DefaultOpenApiFlowClient(openCall));
}
private FlowCenterClient client() {
return new FlowCenterClient(openCall, objectMapper, true, LEGACY_BASE);
}
@Test
@DisplayName("apply:走门面 → 命名目标 + SDK FlowApplyBody 子类 + 出参 instanceId/duplicated")
void apply_goesThroughFacade() {
SvcModels.ApplyResultVo stub = new SvcModels.ApplyResultVo();
stub.setInstanceId(5L);
stub.setDuplicated(true);
when(openCall.post(eq(TARGET), eq("/oapi/flow/apply"), anyMap(),
any(FlowApplyBody.class), eq(SvcModels.ApplyResultVo.class))).thenReturn(stub);
FlowClient.ApplyResult result = client().apply(3L, "guid-1", "svc_item", "1", "刘文娟的教师入职",
"http://base.yunhai/svc-hall/front/#/apply-detail?id=1", "{\"a\":1}", 10133, "刘文娟", null);
assertEquals(Long.valueOf(5L), result.getInstanceId());
assertTrue(result.isDuplicated());
ArgumentCaptor<FlowApplyBody> body = ArgumentCaptor.forClass(FlowApplyBody.class);
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).post(eq(TARGET), eq("/oapi/flow/apply"), query.capture(),
body.capture(), eq(SvcModels.ApplyResultVo.class));
assertEquals("3", query.getValue().get("schId"));
assertEquals("guid-1", body.getValue().getFlowGuid());
assertEquals("svc_item", body.getValue().getBizType());
assertEquals("1", body.getValue().getBizId());
assertEquals(Integer.valueOf(10133), body.getValue().getSubmitterId());
}
@Test
@DisplayName("apply:formDataJson 解析为 formData 对象;空串不传")
void apply_parsesFormData() {
SvcModels.ApplyResultVo stub = new SvcModels.ApplyResultVo();
stub.setInstanceId(6L);
when(openCall.post(eq(TARGET), eq("/oapi/flow/apply"), anyMap(),
any(FlowApplyBody.class), eq(SvcModels.ApplyResultVo.class))).thenReturn(stub);
client().apply(3L, "g", "svc_item", "2", "t", "u", null, 1, "n", "{\"x\":\"y\"}");
ArgumentCaptor<FlowApplyBody> body = ArgumentCaptor.forClass(FlowApplyBody.class);
verify(openCall).post(eq(TARGET), eq("/oapi/flow/apply"), anyMap(),
body.capture(), eq(SvcModels.ApplyResultVo.class));
assertEquals("y", body.getValue().getFormData().get("x"));
}
@Test
@DisplayName("getInstanceByBiz:走预绑命名目标(Map 全字段透传,不用 ad-hoc spec)")
void instance_usesBoundTarget() {
when(openCall.get(eq(TARGET), eq("/oapi/flow/instance"), anyMap(), eq(Map.class)))
.thenReturn(java.util.Collections.singletonMap("status", 20));
Map<String, Object> out = client().getInstanceByBiz(3L, "svc_item", "2");
assertEquals(Integer.valueOf(20), out.get("status"));
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(eq(TARGET), eq("/oapi/flow/instance"), query.capture(), eq(Map.class));
assertEquals("svc_item", query.getValue().get("bizType"));
assertEquals("2", query.getValue().get("bizId"));
assertEquals("3", query.getValue().get("schId"));
}
@Test
@DisplayName("registerCallback:走门面 + basePath 从回调 URL 反推(/svc-hall 剥离口径)")
void registerCallback_goesThroughFacade() {
SvcModels.CallbackRegVo stub = new SvcModels.CallbackRegVo();
stub.setSecret("secret-1");
when(openCall.post(eq(TARGET), eq("/oapi/flow/callback/reg"), anyMap(),
any(FlowCallbackRegBody.class), eq(SvcModels.CallbackRegVo.class))).thenReturn(stub);
String secret = client().registerCallback(3L, "guid-1",
"http://open-gateway-service:10013/svc-hall/oapi/flow/callback", null);
assertEquals("secret-1", secret);
ArgumentCaptor<FlowCallbackRegBody> body = ArgumentCaptor.forClass(FlowCallbackRegBody.class);
verify(openCall).post(eq(TARGET), eq("/oapi/flow/callback/reg"), anyMap(),
body.capture(), eq(SvcModels.CallbackRegVo.class));
assertEquals("/svc-hall", body.getValue().getBasePath());
assertEquals("http://open-gateway-service:10013/svc-hall/oapi/flow/callback", body.getValue().getCallbackUrl());
assertNull(body.getValue().getEventTypes());
}
@Test
@DisplayName("listFlows/getFlow:门面命名目标(回归既有行为)")
void listAndDetail_useFacade() {
when(openCall.getList(eq(TARGET), eq("/oapi/flow/list"), anyMap(), eq(SvcModels.FlowSummaryVo.class)))
.thenReturn(java.util.Collections.singletonList(new SvcModels.FlowSummaryVo()));
when(openCall.get(eq(TARGET), eq("/oapi/flow/guid-1"), anyMap(), eq(SvcModels.FlowSummaryVo.class)))
.thenReturn(new SvcModels.FlowSummaryVo());
assertEquals(1, client().listFlows(3L).size());
org.junit.jupiter.api.Assertions.assertNotNull(client().getFlow(3L, "guid-1"));
}
@Test
@DisplayName("撤回:走门面 → /oapi/flow/instance/{id}/withdraw + schId/submitterId(对端复核身份)")
void withdraw_goesThroughFacade() {
client().withdraw(3L, 77L, 201);
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).post(eq(TARGET), eq("/oapi/flow/instance/77/withdraw"), query.capture(),
isNull(), eq(Void.class));
assertEquals("3", query.getValue().get("schId"));
assertEquals("201", query.getValue().get("submitterId"));
}
@Test
@DisplayName("进度:走门面 → /oapi/flow/progress?instanceId=&schId=")
void progress_goesThroughFacade() {
SvcModels.FlowProgressVo stub = new SvcModels.FlowProgressVo();
stub.setInstanceId(77L);
when(openCall.get(eq(TARGET), eq("/oapi/flow/progress"), anyMap(),
eq(SvcModels.FlowProgressVo.class))).thenReturn(stub);
assertEquals(Long.valueOf(77L), client().progress(3L, 77L, null, null).getInstanceId());
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(eq(TARGET), eq("/oapi/flow/progress"), query.capture(),
eq(SvcModels.FlowProgressVo.class));
assertEquals("77", query.getValue().get("instanceId"));
assertEquals("3", query.getValue().get("schId"));
}
@Test
@DisplayName("流程图:换图 + 取图两步都在后端;token 不出后端(前端拿不到,也不需要 flow-center 地址)")
void diagramImage_tokenStaysInBackend() {
SvcModels.FlowDiagramVo url = new SvcModels.FlowDiagramVo();
url.setImgUrl("/open/flow/diagram/img?token=tok-abc");
url.setExpiresIn(300);
when(openCall.get(eq(TARGET), eq("/oapi/flow/diagram/url"), anyMap(),
eq(SvcModels.FlowDiagramVo.class))).thenReturn(url);
byte[] png = new byte[]{(byte) 0x89, 'P', 'N', 'G'};
when(openCall.download(eq(TARGET), eq("/open/flow/diagram/img"),
eq(java.util.Collections.singletonMap("token", "tok-abc")))).thenReturn(png);
assertEquals(png, client().diagramImage(3L, 77L, null, "dept"));
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(eq(TARGET), eq("/oapi/flow/diagram/url"), query.capture(),
eq(SvcModels.FlowDiagramVo.class));
assertEquals("77", query.getValue().get("instanceId"));
assertEquals("dept", query.getValue().get("mode"));
verify(openCall).download(eq(TARGET), eq("/open/flow/diagram/img"),
eq(java.util.Collections.singletonMap("token", "tok-abc")));
}
@Test
@DisplayName("tokenOf:只认 ?token= 段,容忍尾部其它参数;无 token 返回 null(不拼出脏地址)")
void tokenOf_parsesTokenOnly() {
assertEquals("abc", FlowCenterClient.tokenOf("/open/flow/diagram/img?token=abc"));
assertEquals("abc", FlowCenterClient.tokenOf("/open/flow/diagram/img?token=abc&x=1"));
assertEquals("abc", FlowCenterClient.tokenOf("http://h/flow-center/open/flow/diagram/img?token=abc"));
org.junit.jupiter.api.Assertions.assertNull(FlowCenterClient.tokenOf("/open/flow/diagram/img"));
org.junit.jupiter.api.Assertions.assertNull(FlowCenterClient.tokenOf(null));
org.junit.jupiter.api.Assertions.assertNull(FlowCenterClient.tokenOf("/x?token="));
}
@Test
@DisplayName("目录未配:撤回落 legacy base-url(ad-hoc TargetSpec POST,行为不变)")
void withdraw_fallsBackToLegacy() {
YschOpenCall legacyCall = mock(YschOpenCall.class);
when(legacyCall.hasTarget(TARGET)).thenReturn(false);
new FlowCenterClient(legacyCall, objectMapper, true, LEGACY_BASE).withdraw(3L, 77L, 201);
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(legacyCall).post(any(com.yunhai.platform.openapi.core.TargetSpec.class),
eq("/oapi/flow/instance/77/withdraw"), query.capture(), isNull(), eq(Void.class));
assertEquals("201", query.getValue().get("submitterId"));
}
@Test
@DisplayName("enabled=false:三个新方法一律抛「未接入」(不静默成功让前端误判)")
void disabledClient_rejectsNewActions() {
YschOpenCall noDirectory = mock(YschOpenCall.class);
when(noDirectory.hasTarget(TARGET)).thenReturn(false);
FlowCenterClient c = new FlowCenterClient(noDirectory, objectMapper, false, "");
com.yunhai.platform.apistd.BizException withdrawEx =
org.junit.jupiter.api.Assertions.assertThrows(com.yunhai.platform.apistd.BizException.class,
() -> c.withdraw(3L, 77L, 201));
org.junit.jupiter.api.Assertions.assertTrue(withdrawEx.getMessage().contains("流程中心未接入"));
org.junit.jupiter.api.Assertions.assertThrows(com.yunhai.platform.apistd.BizException.class,
() -> c.progress(3L, 77L, null, null));
org.junit.jupiter.api.Assertions.assertThrows(com.yunhai.platform.apistd.BizException.class,
() -> c.diagramImage(3L, 77L, null, null));
}
@Test
@DisplayName("目录未配:回落 legacy svchall.flow.base-url(迁移期兼容,行为不变)")
void fallsBackToLegacyWhenDirectoryMissing() {
YschOpenCall legacyCall = mock(YschOpenCall.class);
when(legacyCall.hasTarget(TARGET)).thenReturn(false);
when(legacyCall.post(any(com.yunhai.platform.openapi.core.TargetSpec.class),
eq("/oapi/flow/apply"), anyMap(), any(), eq(com.fasterxml.jackson.databind.JsonNode.class)))
.thenReturn(objectMapper.createObjectNode().put("instanceId", 9));
FlowClient.ApplyResult result = new FlowCenterClient(legacyCall, objectMapper, true, LEGACY_BASE)
.apply(3L, "g", "svc_item", "1", "t", "u", null, 1, "n", null);
assertEquals(Long.valueOf(9L), result.getInstanceId());
assertFalse(result.isDuplicated());
verify(legacyCall).post(any(com.yunhai.platform.openapi.core.TargetSpec.class),
eq("/oapi/flow/apply"), anyMap(), any(), eq(com.fasterxml.jackson.databind.JsonNode.class));
}
@Test
@DisplayName("目录与 legacy 键皆未配且 enabled=true:构造期 fail-fast(不静默打 localhost)")
void misconfiguredFailsFast() {
YschOpenCall noDirectory = mock(YschOpenCall.class);
when(noDirectory.hasTarget(TARGET)).thenReturn(false);
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> new FlowCenterClient(noDirectory, objectMapper, true, ""));
assertTrue(e.getMessage().contains("yh.open.services." + TARGET), e.getMessage());
assertTrue(e.getMessage().contains("svchall.flow.base-url"), e.getMessage());
}
@Test
@DisplayName("enabled=false 且地址全缺:不 fail-fast(显式未接入)")
void disabledWithoutAddressIsAllowed() {
YschOpenCall noDirectory = mock(YschOpenCall.class);
when(noDirectory.hasTarget(TARGET)).thenReturn(false);
FlowCenterClient c = new FlowCenterClient(noDirectory, objectMapper, false, "");
assertFalse(c.enabled());
com.yunhai.platform.apistd.BizException ex =
org.junit.jupiter.api.Assertions.assertThrows(com.yunhai.platform.apistd.BizException.class,
() -> c.listFlows(3L));
assertTrue(ex.getMessage().contains("流程中心未接入"), ex.getMessage());
}
@Test
void v2ResultRetainsBranchRoundAndRealTerminalStatus() {
com.yunhai.svchall.model.FcApplyModels.FlowOutbound outbound = new com.yunhai.svchall.model.FcApplyModels.FlowOutbound();
outbound.setBizId("42"); outbound.setRequestToken("token-1");
when(openCall.post(eq(TARGET), eq("/oapi/flow/apply/v2"), anyMap(), any(), eq(com.fasterxml.jackson.databind.JsonNode.class)))
.thenReturn(objectMapper.createObjectNode().put("instanceId", 555).put("duplicated", true)
.put("branch", "SAME_REQUEST").put("applyRound", 1).put("status", 30).put("version", 4));
FlowClient.ApplyResult result = client().applyWithIdentity(3L, outbound);
assertEquals("SAME_REQUEST", result.getBranch()); assertEquals(Integer.valueOf(1), result.getApplyRound());
assertEquals(Integer.valueOf(30), result.getStatus()); assertEquals(Integer.valueOf(4), result.getVersion());
assertEquals(Long.valueOf(555), result.getInstanceId());
}
@Test
void v2DoesNotTruncateFractionalRound() {
com.yunhai.svchall.model.FcApplyModels.FlowOutbound outbound = new com.yunhai.svchall.model.FcApplyModels.FlowOutbound();
outbound.setBizId("42"); outbound.setRequestToken("token-1");
when(openCall.post(eq(TARGET), eq("/oapi/flow/apply/v2"), anyMap(), any(), eq(com.fasterxml.jackson.databind.JsonNode.class)))
.thenReturn(objectMapper.createObjectNode().put("instanceId", 555).put("branch", "RESTARTED").put("applyRound", 2.5));
assertThrows(com.yunhai.platform.apistd.BizException.class, () -> client().applyWithIdentity(3L, outbound));
}
}