package rpc import ( "context" "testing" "time" "github.com/iamxvbaba/td/tg" "telesrv/internal/domain" ) // "see https://example.com/x" 的 URL 在 UTF-16 偏移 4、长度 21。 const ( wpTestMessage = "see https://example.com/x" wpTestURL = "https://example.com/x" wpURLEntityOff = 4 wpURLEntityLen = 21 ) func wpURLEntities() []tg.MessageEntityClass { return []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: wpURLEntityOff, Length: wpURLEntityLen}} } // TestSendMessageAttachesWebPagePending 验证启用预览时,带 URL 的文本消息挂上 pending 占位 // (id==url_hash 保证与 done 解析关联),并投影 invert_media。 func TestSendMessageAttachesWebPagePending(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) r.deps.Files.(*fakeFiles).webPagePreviewOn = true now := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC) r.clock = fixedClock{now: now} updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Message: wpTestMessage, Entities: wpURLEntities(), RandomID: 5101, InvertMedia: true, }) if err != nil { t.Fatalf("sendMessage: %v", err) } msg := newMessageFromUpdates(t, updates) wrap, ok := msg.Media.(*tg.MessageMediaWebPage) if !ok { t.Fatalf("media = %T, want *tg.MessageMediaWebPage", msg.Media) } pending, ok := wrap.Webpage.(*tg.WebPagePending) if !ok { t.Fatalf("webpage = %T, want *tg.WebPagePending", wrap.Webpage) } wantID := domain.WebPageURLHash(wpTestURL) if pending.ID != wantID { t.Errorf("pending id = %d, want url_hash %d", pending.ID, wantID) } if url, _ := pending.GetURL(); url != wpTestURL { t.Errorf("pending url = %q, want %q", url, wpTestURL) } wantDeadline := int(now.Add(webPagePendingLifetime).Unix()) if pending.Date != wantDeadline { t.Errorf("pending date = %d, want retry deadline %d", pending.Date, wantDeadline) } if time.Unix(int64(pending.Date), 0).Sub(now) <= webPageResolveTimeout { t.Errorf("pending deadline must outlive resolver timeout: deadline=%s timeout=%s", time.Unix(int64(pending.Date), 0), webPageResolveTimeout) } if !msg.InvertMedia { t.Errorf("invert_media not projected onto message") } } // TestSendChannelMessageUsesSameFutureWebPageDeadline 锁定频道发送也经过同一 pending // 截止时间构造路径,避免只修私聊 echo 而频道仍被客户端立即判定过期。 func TestSendChannelMessageUsesSameFutureWebPageDeadline(t *testing.T) { ctx := context.Background() r, owner, channel := newRichChannelTestRouter(t) r.deps.Files.(*fakeFiles).webPagePreviewOn = true // 本测试只验证发送投影,不启动异步解析 goroutine。 r.webPageResolveSem = nil now := time.Date(2030, time.February, 3, 4, 5, 6, 0, time.UTC) r.clock = fixedClock{now: now} updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, Message: wpTestMessage, Entities: wpURLEntities(), RandomID: 5106, }) if err != nil { t.Fatalf("send channel message: %v", err) } msg := newMessageFromUpdates(t, updates) wrap, ok := msg.Media.(*tg.MessageMediaWebPage) if !ok { t.Fatalf("channel media = %T, want *tg.MessageMediaWebPage", msg.Media) } pending, ok := wrap.Webpage.(*tg.WebPagePending) if !ok { t.Fatalf("channel webpage = %T, want *tg.WebPagePending", wrap.Webpage) } if want := int(now.Add(webPagePendingLifetime).Unix()); pending.Date != want { t.Errorf("channel pending date = %d, want retry deadline %d", pending.Date, want) } } // TestSendMessageAttachesCachedDoneCard 验证:URL 已缓存解析时,发送 echo 直接带 done 卡片 // (非 pending)——官方行为,TDesktop 据此立即渲染、不依赖异步换卡。 func TestSendMessageAttachesCachedDoneCard(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) f := r.deps.Files.(*fakeFiles) f.webPagePreviewOn = true f.lookupWebPageFn = func(u string) (domain.MessageWebPage, bool) { return domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: domain.WebPageURLHash(u), URL: u, DisplayURL: "example.com", Title: "Cached"}, true } updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Message: wpTestMessage, Entities: wpURLEntities(), RandomID: 5201, }) if err != nil { t.Fatalf("sendMessage: %v", err) } msg := newMessageFromUpdates(t, updates) wrap, ok := msg.Media.(*tg.MessageMediaWebPage) if !ok { t.Fatalf("media = %T, want *tg.MessageMediaWebPage", msg.Media) } page, ok := wrap.Webpage.(*tg.WebPage) if !ok { t.Fatalf("echo webpage = %T, want *tg.WebPage (done card directly)", wrap.Webpage) } if v, _ := page.GetTitle(); v != "Cached" { t.Errorf("title = %q, want Cached", v) } } // TestSendMessageNoWebpageSuppressesPreview 验证 no_webpage 抑制占位。 func TestSendMessageNoWebpageSuppressesPreview(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) r.deps.Files.(*fakeFiles).webPagePreviewOn = true updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Message: wpTestMessage, Entities: wpURLEntities(), RandomID: 5102, NoWebpage: true, }) if err != nil { t.Fatalf("sendMessage: %v", err) } if msg := newMessageFromUpdates(t, updates); msg.Media != nil { t.Fatalf("media = %T, want nil (no_webpage)", msg.Media) } } // TestSendMessagePreviewDisabledNoPlaceholder 验证未启用预览时不挂占位(否则永久 pending)。 func TestSendMessagePreviewDisabledNoPlaceholder(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) // webPagePreviewOn 默认 false。 updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Message: wpTestMessage, Entities: wpURLEntities(), RandomID: 5103, }) if err != nil { t.Fatalf("sendMessage: %v", err) } if msg := newMessageFromUpdates(t, updates); msg.Media != nil { t.Fatalf("media = %T, want nil (preview disabled)", msg.Media) } } // TestSendMediaInputWebPageAttachesPending 验证 sendMedia 的 InputMediaWebPage 经降级到 // sendMessage 后,仍从文本 URL 挂上 pending 占位(启用预览时)。 func TestSendMediaInputWebPageAttachesPending(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) r.deps.Files.(*fakeFiles).webPagePreviewOn = true updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Media: &tg.InputMediaWebPage{URL: wpTestURL}, Message: wpTestMessage, Entities: wpURLEntities(), RandomID: 5105, }) if err != nil { t.Fatalf("sendMedia webpage: %v", err) } msg := newMessageFromUpdates(t, updates) wrap, ok := msg.Media.(*tg.MessageMediaWebPage) if !ok { t.Fatalf("media = %T, want *tg.MessageMediaWebPage", msg.Media) } if _, ok := wrap.Webpage.(*tg.WebPagePending); !ok { t.Fatalf("webpage = %T, want *tg.WebPagePending", wrap.Webpage) } } // TestSendMessageHighlightsBareURL 验证服务端为不带 url 实体的消息(如 TDesktop 发的)补 // MessageEntityURL,使链接高亮——独立于预览是否启用。 func TestSendMessageHighlightsBareURL(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Message: "see https://example.com/x", RandomID: 5301, // 无 entities,模拟 TDesktop }) if err != nil { t.Fatalf("sendMessage: %v", err) } msg := newMessageFromUpdates(t, updates) var found bool for _, e := range msg.Entities { if u, ok := e.(*tg.MessageEntityURL); ok && u.Offset == 4 && u.Length == 21 { found = true } } if !found { t.Fatalf("sent message missing url highlight entity: %+v", msg.Entities) } } // TestSendMessageHighlightsConfiguredAppLink locks the server-only compatibility // contract: clients may omit the custom-scheme entity, while the persisted/echoed // message still carries MessageEntityURL and never starts webpage resolution. func TestSendMessageHighlightsConfiguredAppLink(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) r.deps.Files.(*fakeFiles).webPagePreviewOn = true message := "👍 telesrv://resolve?domain=Alice" updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Message: message, RandomID: 5302, }) if err != nil { t.Fatalf("sendMessage: %v", err) } msg := newMessageFromUpdates(t, updates) if msg.Media != nil { t.Fatalf("custom app-link media = %T, want nil", msg.Media) } for _, entity := range msg.Entities { if url, ok := entity.(*tg.MessageEntityURL); ok && url.Offset == 3 && url.Length == utf16CodeUnitLen("telesrv://resolve?domain=Alice") { return } } t.Fatalf("sent message missing configured app-link entity: %+v", msg.Entities) } // TestSendMessageFillsCustomLinkBesideClientHTTPEntity covers the partial-client // case exposed by Android: an existing HTTP entity must not suppress app-link detection. func TestSendMessageFillsCustomLinkBesideClientHTTPEntity(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) message := "https://x telesrv://resolve?domain=Alice" updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Message: message, Entities: []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: 0, Length: utf16CodeUnitLen("https://x")}}, RandomID: 5303, }) if err != nil { t.Fatalf("sendMessage: %v", err) } msg := newMessageFromUpdates(t, updates) if len(msg.Entities) != 2 { t.Fatalf("message entities = %+v, want client HTTP plus server app-link", msg.Entities) } custom, ok := msg.Entities[1].(*tg.MessageEntityURL) if !ok || custom.Offset != utf16CodeUnitLen("https://x ") || custom.Length != utf16CodeUnitLen("telesrv://resolve?domain=Alice") { t.Fatalf("custom entity = %#v", msg.Entities[1]) } } func TestAutoEntityDerivationDoesNotMutateSendRequests(t *testing.T) { t.Run("send-message-replay", func(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) req := &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Message: "telesrv://resolve?domain=Alice", RandomID: 5304, } first, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), req) if err != nil { t.Fatalf("first sendMessage: %v", err) } if len(req.Entities) != 0 { t.Fatalf("sendMessage request was mutated: %+v", req.Entities) } if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), req); err != nil { t.Fatalf("sendMessage replay: %v", err) } if got := newMessageFromUpdates(t, first); len(got.Entities) != 1 { t.Fatalf("first send entities = %+v, want derived app-link", got.Entities) } }) t.Run("send-media-replay", func(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) req := &tg.MessagesSendMediaRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Media: &tg.InputMediaContact{PhoneNumber: "+15550005305", FirstName: "Alice"}, Message: "telesrv://resolve?domain=Alice", RandomID: 5305, } first, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), req) if err != nil { t.Fatalf("first sendMedia: %v", err) } if len(req.Entities) != 0 { t.Fatalf("sendMedia request was mutated: %+v", req.Entities) } if _, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), req); err != nil { t.Fatalf("sendMedia replay: %v", err) } if got := newMessageFromUpdates(t, first); len(got.Entities) != 1 { t.Fatalf("first media caption entities = %+v, want derived app-link", got.Entities) } }) } // TestSendMessageNoURLNoPlaceholder 验证无 URL 实体时不挂占位。 func TestSendMessageNoURLNoPlaceholder(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) r.deps.Files.(*fakeFiles).webPagePreviewOn = true updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Message: "just plain text, no link", RandomID: 5104, }) if err != nil { t.Fatalf("sendMessage: %v", err) } if msg := newMessageFromUpdates(t, updates); msg.Media != nil { t.Fatalf("media = %T, want nil (no url)", msg.Media) } }