Files
UserhomeService/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java
T

334 lines
18 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.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)。
* <p>
* 出站通道 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<TargetSpec> 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<String, Object> out = service.list(201, 2, 20, Boolean.TRUE);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> 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<Map<String, String>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> out = service.summary(201, null);
assertEquals(5L, out.get("unreadCount"));
}
@Test
void summary_disabled_degradesToZero() {
properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false);
Map<String, Object> 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<Map<String, String>> 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<String, Object> out = service.summary(201, 1);
assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出");
}
}