package vip.mate.channel.wecom; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import vip.mate.channel.ChannelMessageRouter; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.notification.ApprovalNotificationService; import vip.mate.channel.wecom.cards.WeComCardDispatcher; import vip.mate.workspace.conversation.model.MessageContentPart; import java.lang.reflect.Method; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; /** * Pin {@code msgtype=appmsg} parsing — covers the four sub-variants * users actually forward to bots in production: PDF / Word / Excel * (file), image cards, miniprograms, and public-account article links. * *

Without this branch, every forwarded PDF / article / miniprogram * fell into the inbound switch's default and got silently dropped. * These tests pin (1) the text marker shape so prompts stay stable, * (2) the attached-media routing for file and image variants, and * (3) the link/miniprogram fallbacks so the agent at least knows * something was shared. */ class AppmsgContentTest { private WeComChannelAdapter adapter; private Method extract; @BeforeEach void setUp() throws Exception { ChannelEntity entity = new ChannelEntity(); entity.setId(1L); entity.setChannelType("wecom"); entity.setConfigJson("{\"media_download_enabled\": false}"); adapter = new WeComChannelAdapter( entity, Mockito.mock(ChannelMessageRouter.class), new ObjectMapper(), Mockito.mock(ApprovalNotificationService.class), Mockito.mock(WeComCardDispatcher.class), Mockito.mock(WeComKeepaliveScheduler.class)); extract = WeComChannelAdapter.class.getDeclaredMethod( "extractAppmsgContent", Map.class, String.class, String.class, String.class, String.class); extract.setAccessible(true); } @SuppressWarnings("unchecked") private Object invoke(Map body) throws Exception { return extract.invoke(adapter, body, "msg-1", "alice", "alice", "single"); } private String text(Object ctx) throws Exception { return (String) ctx.getClass().getMethod("text").invoke(ctx); } @SuppressWarnings("unchecked") private List parts(Object ctx) throws Exception { return (List) ctx.getClass().getMethod("attachedParts").invoke(ctx); } @Test @DisplayName("appmsg.file → file content part + [文件: filename] marker") void fileVariant() throws Exception { Object ctx = invoke(Map.of("appmsg", Map.of( "title", "report.pdf", "file", Map.of( "url", "https://example.com/report.pdf", "aeskey", "k", "filename", "report.pdf")))); assertEquals("[文件: report.pdf]", text(ctx)); assertEquals(1, parts(ctx).size()); assertEquals("file", parts(ctx).get(0).getType()); } @Test @DisplayName("appmsg.image → image content part + [图片: title] marker") void imageVariant() throws Exception { Object ctx = invoke(Map.of("appmsg", Map.of( "title", "周末聚会", "image", Map.of( "url", "https://example.com/photo.jpg", "aeskey", "k")))); assertEquals("[图片: 周末聚会]", text(ctx)); assertEquals(1, parts(ctx).size()); assertEquals("image", parts(ctx).get(0).getType()); } @Test @DisplayName("appmsg.image with no title → bare [图片] marker") void imageVariantNoTitle() throws Exception { Object ctx = invoke(Map.of("appmsg", Map.of( "image", Map.of( "url", "https://example.com/p.jpg", "aeskey", "k")))); assertEquals("[图片]", text(ctx)); assertEquals(1, parts(ctx).size()); } @Test @DisplayName("appmsg.miniprogram → [小程序: title] marker, no attached media") void miniprogramVariant() throws Exception { Object ctx = invoke(Map.of("appmsg", Map.of( "title", "外卖小程序", "miniprogram", Map.of("title", "美团外卖")))); assertEquals("[小程序: 美团外卖]", text(ctx)); assertTrue(parts(ctx).isEmpty()); } @Test @DisplayName("appmsg.miniprogram with no inner title falls back to top-level title") void miniprogramTitleFallback() throws Exception { Object ctx = invoke(Map.of("appmsg", Map.of( "title", "顶层标题", "miniprogram", Map.of()))); assertEquals("[小程序: 顶层标题]", text(ctx)); } @Test @DisplayName("appmsg.url (public-account article) → [链接] + title + desc + url multi-line + paste-body hint") void linkVariant() throws Exception { Object ctx = invoke(Map.of("appmsg", Map.of( "title", "深度好文:AI 的未来", "description", "本文探讨 AI 在企业的落地路径", "url", "https://mp.weixin.qq.com/s/abc123"))); String t = text(ctx); assertTrue(t.startsWith("[链接] 深度好文:AI 的未来"), "title should follow [链接] tag; got: " + t); assertTrue(t.contains("本文探讨 AI 在企业的落地路径"), "description must be present; got: " + t); assertTrue(t.contains("https://mp.weixin.qq.com/s/abc123"), "URL must be in the text so agent can reference it; got: " + t); // Public-account body is captcha-gated — agent must be told not to // hallucinate content from the title. assertTrue(t.contains("公众号文章"), "public-account article hint must be appended; got: " + t); assertTrue(t.contains("不要凭标题猜测内容"), "directive against title-only guessing must be present; got: " + t); assertTrue(parts(ctx).isEmpty(), "link variant produces no attached media"); } @Test @DisplayName("non-public-account links (regular URLs) do NOT get the paste-body hint") void linkVariantNonWeixinUrlNoHint() throws Exception { // Generic web links don't have the captcha-gate problem — fetching // the body via a tool is straightforward, so adding the hint would // be misleading. Object ctx = invoke(Map.of("appmsg", Map.of( "title", "GitHub README", "url", "https://github.com/example/repo"))); String t = text(ctx); assertTrue(t.contains("https://github.com/example/repo")); assertFalse(t.contains("公众号文章"), "non-mp.weixin.qq.com URLs must not trigger the public-account hint; got: " + t); } @Test @DisplayName("link with title only, no description") void linkVariantNoDesc() throws Exception { Object ctx = invoke(Map.of("appmsg", Map.of( "title", "标题", "url", "https://example.com"))); String t = text(ctx); assertTrue(t.contains("[链接] 标题")); assertTrue(t.contains("https://example.com")); } @Test @DisplayName("unknown appmsg variant with title → [appmsg: title] marker") void unknownVariantWithTitle() throws Exception { Object ctx = invoke(Map.of("appmsg", Map.of( "title", "未知卡片", "weird_field", Map.of()))); assertEquals("[appmsg: 未知卡片]", text(ctx)); } @Test @DisplayName("totally empty appmsg → bare [appmsg] marker (agent at least knows something arrived)") void emptyAppmsg() throws Exception { Object ctx = invoke(Map.of("appmsg", Map.of())); assertEquals("[appmsg]", text(ctx)); assertTrue(parts(ctx).isEmpty()); } @Test @DisplayName("file variant uses appmsg.title as filename when file.filename missing") void fileFilenameFallback() throws Exception { Object ctx = invoke(Map.of("appmsg", Map.of( "title", "周报.docx", "file", Map.of( "url", "https://example.com/x", "aeskey", "k")))); // filename comes from title since file.filename is absent assertTrue(text(ctx).contains("周报.docx"), "marker should carry the title as filename; got: " + text(ctx)); } }