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} 时, * 全部出站(listFlows / getFlow / apply / instance / callback·reg)必须走 SDK 命名目标 * (门面 {@link YschFlowOpenApi} 或预绑 {@code BoundOpenCall}),不再回落应用专属 * {@code svchall.flow.base-url}(《子应用配置优化方案》§4.2 零专属地址配置)。

* 此前只有 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 body = ArgumentCaptor.forClass(FlowApplyBody.class); ArgumentCaptor> 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 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 out = client().getInstanceByBiz(3L, "svc_item", "2"); assertEquals(Integer.valueOf(20), out.get("status")); ArgumentCaptor> 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 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> 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> 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> 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> 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)); } }