package com.yunhai.userhome.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.platform.openapi.core.OpenApiException; import com.yunhai.platform.openapi.core.TargetSpec; import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.userhome.config.MsgCenterProperties; 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.HashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 消息中心代理单测(§3.5,BP-R3-1):冻结 UserMsg oapi 契约 * (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)与 * jumpUrl 改写规则(jumpType=1 时:绝对 URL 直接透传;相对路径按「页面归属应用 jumpApp → * bizType 回落」查基址 → 基址 + "/#" + jumpPath)。 * UserMsg 不可达降级空结果(铃铛不 500)。 *

* 出站通道 2026-09-20 起由 IDHttp 换 YschOpenCall(泛化签名直调,Tier 0): * 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时 * 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从 * usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。 */ @DisplayName("MsgCenterProxyService - YschOpenCall 代理与 jumpUrl 改写") class MsgCenterProxyServiceTest { /** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */ private static final String PROD_BASE_URL = "http://usermsg-service:10009/usermsg"; private YschOpenCall openCall; private MsgCenterProperties properties; private MsgCenterProxyService service; @BeforeEach void setUp() { openCall = mock(YschOpenCall.class); properties = new MsgCenterProperties(); properties.setUsermsgBaseUrl(PROD_BASE_URL); properties.getJumpBases().put("flow.task", "http://localhost:8088/"); service = new MsgCenterProxyService(openCall, properties); } private static JsonNode json(String s) throws Exception { return new ObjectMapper().readTree(s); } @Test void list_targetSpecStripsUsermsgCtx_forPerTargetSign() { // 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(null); service.list(201, 1, 10, null); ArgumentCaptor spec = ArgumentCaptor.forClass(TargetSpec.class); verify(openCall).get(spec.capture(), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)); assertEquals("http://usermsg-service:10009", spec.getValue().getBaseHost()); assertEquals("/usermsg", spec.getValue().getBasePath(), "basePath=目标 ctx,AppSignInterceptor 据此剥离后签名"); } @Test void list_buildsQueryParams_andRewritesJumpUrl() throws Exception { properties.getAppNames().put("100617", "通知公告"); when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":2,\"content\":[" + "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2," + "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\"," + "\"bizId\":\"123\",\"sendTime\":\"2026-09-13 10:00:00\",\"isRead\":0}," + "{\"msgId\":2,\"title\":\"无跳转\",\"content\":\"系统通知\",\"msgType\":0," + "\"jumpType\":0,\"jumpPath\":null,\"bizType\":null,\"isRead\":1}]}")); Map out = service.list(201, 2, 20, Boolean.TRUE); @SuppressWarnings("unchecked") ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class)); assertEquals("201", query.getValue().get("userId")); assertEquals("2", query.getValue().get("pageIndex")); assertEquals("20", query.getValue().get("pageSize")); assertEquals("true", query.getValue().get("onlyUnread")); assertEquals(2, out.get("total")); java.util.List rows = (java.util.List) out.get("list"); Map first = (Map) rows.get(0); assertEquals("http://localhost:8088/#/task/123", first.get("jumpUrl"), "基址+/#+裸路径"); assertEquals("flow.task", first.get("bizType")); assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名"); assertNull(((Map) rows.get(1)).get("jumpUrl"), "非应用内跳转不改写"); } @Test void list_defaultsPageParams() { when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(null); service.list(201, 0, 999, null); @SuppressWarnings("unchecked") ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class)); assertEquals("1", query.getValue().get("pageIndex"), "非法页码回退 1"); assertEquals("100", query.getValue().get("pageSize"), "页长上限 100"); assertEquals("false", query.getValue().get("onlyUnread")); } @Test void list_appNameMappedFromAppKey() throws Exception { properties.getAppNames().put("100617", "通知公告"); when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\"," + "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}")); Map out = service.list(201, 1, 10, null); Map first = (Map) ((java.util.List) out.get("list")).get(0); assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名"); } @Test void list_noBaseConfigured_jumpUrlNull() throws Exception { properties.getJumpBases().clear(); when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}")); Map out = service.list(201, 1, 10, null); Map first = (Map) ((java.util.List) out.get("list")).get(0); assertNull(first.get("jumpUrl"), "bizType 未配基址 → 不改写(前端不渲染跳转)"); } // ===== Phase 2(自指地址优化):归属优先 + 绝对 URL 透传 ===== /** 代发待办:bizType=flow.task 但页面属业务方(jumpApp=notice)→ 按 notice 基址拼(B1) */ @Test void list_jumpAppPreferredOverBizType() throws Exception { properties.getJumpBases().put("notice", "http://localhost:8077"); when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + "\"jumpPath\":\"/list/detail?serid=1\",\"bizType\":\"flow.task\",\"jumpApp\":\"notice\"," + "\"isRead\":0}]}")); Map out = service.list(201, 1, 10, null); Map first = (Map) ((java.util.List) out.get("list")).get(0); assertEquals("http://localhost:8077/#/list/detail?serid=1", first.get("jumpUrl"), "归属应用优先于 bizType 查基址"); assertEquals("notice", first.get("jumpApp"), "jumpApp 逐字段透传"); } /** 存量兼容:改造前落库的绝对 URL 直接透传,不再拼基址(B5;双前缀缺陷即源于二次拼接) */ @Test void list_absoluteJumpPath_passedThrough() throws Exception { when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/detail?serid=1\"," + "\"bizType\":\"flow.task\",\"isRead\":0}]}")); Map out = service.list(201, 1, 10, null); Map first = (Map) ((java.util.List) out.get("list")).get(0); assertEquals("http://base.yunhaixingtong.com/notice/#/list/detail?serid=1", first.get("jumpUrl"), "绝对 URL 原样透传"); } /** 灰度兼容:旧发送端未升级(无 jumpApp)→ 回落按 bizType 查(= 改造前行为) */ @Test void list_jumpAppAbsent_fallsBackToBizType() throws Exception { when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}")); Map out = service.list(201, 1, 10, null); Map first = (Map) ((java.util.List) out.get("list")).get(0); assertEquals("http://localhost:8088/#/task/9", first.get("jumpUrl"), "无归属声明回落 bizType"); } /** 归属键未配基址(如催办 notice.expedite 场景 jumpApp 未升级)→ 仍回落 bizType */ @Test void list_jumpAppMiss_fallsBackToBizType() throws Exception { when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"jumpApp\":\"unknown-app\"," + "\"isRead\":0}]}")); Map out = service.list(201, 1, 10, null); Map first = (Map) ((java.util.List) out.get("list")).get(0); assertEquals("http://localhost:8088/#/task/9", first.get("jumpUrl")); } /** 归属与 bizType 都查不到基址 → null(不渲染跳转) */ @Test void list_jumpAppAndBizTypeBothMiss_null() throws Exception { when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + "\"jumpPath\":\"/x\",\"bizType\":\"flow.nowhere\",\"jumpApp\":\"nowhere\"," + "\"isRead\":0}]}")); Map out = service.list(201, 1, 10, null); Map first = (Map) ((java.util.List) out.get("list")).get(0); assertNull(first.get("jumpUrl")); } /** 基址尾部斜杠归一保留:jumpApp 命中带尾斜杠的基址也不产出双斜杠 */ @Test void list_jumpAppBaseTrailingSlash_normalized() throws Exception { properties.getJumpBases().put("notice", "http://localhost:8077/"); when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + "\"jumpPath\":\"list/detail?serid=1\",\"jumpApp\":\"notice\",\"isRead\":0}]}")); Map out = service.list(201, 1, 10, null); Map first = (Map) ((java.util.List) out.get("list")).get(0); assertEquals("http://localhost:8077/#/list/detail?serid=1", first.get("jumpUrl"), "基址尾斜杠归一 + 无前导斜杠路径补斜杠"); } @Test void list_userMsgDown_degradesToEmpty() { when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401")); Map out = service.list(201, 1, 10, null); assertEquals(0, out.get("total")); assertEquals(0, ((java.util.List) out.get("list")).size()); } @Test void wsTicket_returnsUrlWithTicket() throws Exception { properties.setUsermsgWsUrl("ws://base.yunhaixingtong.com/usermsg/ws/msg"); when(openCall.post(any(TargetSpec.class), eq("/oapi/msg/ws/ticket"), anyMap(), any(Map.class), eq(JsonNode.class))) .thenReturn(json("{\"ticket\":\"abc123ticket\",\"expiresIn\":60}")); String url = service.createWsTicket(201); assertEquals("ws://base.yunhaixingtong.com/usermsg/ws/msg?ticket=abc123ticket", url); } @Test void wsTicket_wsUrlNotConfigured_nullWithoutOutbound() { properties.setUsermsgWsUrl(""); assertNull(service.createWsTicket(201)); verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any()); } @Test void wsTicket_userMsgDown_degradesNull() { properties.setUsermsgWsUrl("ws://localhost:8080/ws/msg"); when(openCall.post(any(TargetSpec.class), eq("/oapi/msg/ws/ticket"), anyMap(), any(Map.class), eq(JsonNode.class))) .thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401")); assertNull(service.createWsTicket(201), "票据失败降级 null,前端不建立长连接"); } @Test void summary_combinesUnreadAndTodos_withPerCallDegrade() throws Exception { when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) .thenReturn(json("3")); when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("[{\"appKey\":\"100019\",\"count\":2}]")); Map out = service.summary(201, 1); assertEquals(3L, out.get("unreadCount")); assertEquals(1, ((java.util.List) out.get("todos")).size()); // 未读数不可达单独降级为 0,待办仍可用 when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) .thenThrow(new OpenApiException("down")); Map out2 = service.summary(201, 1); assertEquals(0L, out2.get("unreadCount")); assertEquals(1, ((java.util.List) out2.get("todos")).size()); } @Test void read_sendsOwnerScopedPayload() { service.read(201, java.util.Collections.singletonList(7L)); verify(openCall).post(any(TargetSpec.class), eq("/oapi/msg/read"), anyMap(), any(Map.class), eq(JsonNode.class)); } @Test void disabled_degradesWithoutOutbound() { properties.setUsermsgBaseUrl(""); when(openCall.hasTarget("usermsg")).thenReturn(false); assertEquals(0, service.list(201, 1, 10, null).get("total")); service.read(201, java.util.Collections.singletonList(7L)); verify(openCall, never()).get(any(TargetSpec.class), anyString(), anyMap(), any()); verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any()); } @Test void disabled_fallsBackToDirectoryTarget_whenBaseUrlBlank() throws Exception { // base-url 未配但服务目录有 usermsg(平台公共配置形态):按名调用仍可用 properties.setUsermsgBaseUrl(""); when(openCall.hasTarget("usermsg")).thenReturn(true); when(openCall.get(eq("usermsg"), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) .thenReturn(json("5")); Map out = service.summary(201, null); assertEquals(5L, out.get("unreadCount")); } @Test void summary_disabled_degradesToZero() { properties.setUsermsgBaseUrl(""); when(openCall.hasTarget("usermsg")).thenReturn(false); Map out = service.summary(201, 1); assertEquals(0L, out.get("unreadCount")); assertEquals(0, ((java.util.List) out.get("todos")).size()); } @Test void summary_todoQueryIncludesSchIdOnly_whenNotNull() throws Exception { when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) .thenReturn(json("0")); when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("[]")); service.summary(201, null); service.summary(201, 3); @SuppressWarnings("unchecked") ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); verify(openCall, org.mockito.Mockito.times(2)) .get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), query.capture(), eq(JsonNode.class)); assertEquals(1, query.getAllValues().get(0).size(), "schId=null 不下发该参数"); assertEquals("3", query.getAllValues().get(1).get("schId")); } @Test void malformedBaseUrl_disabledNotCrash() { properties.setUsermsgBaseUrl("::::not-a-url"); Map out = service.summary(201, 1); assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出"); } }