owpengram-server/internal/rpc/webpage_url_extract_test.go

347 lines
13 KiB
Go
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 rpc
import (
"testing"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/links"
)
var testDefaultAppLinks = func() links.AppLinkBuilder {
appLinks, err := links.NewAppLinkBuilder("telesrv", "")
if err != nil {
panic(err)
}
return appLinks
}()
func testAugmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.MessageEntityClass {
return augmentAutoEntities(message, entities, testDefaultAppLinks)
}
func testAugmentAutoEntitiesWithAppLinks(t *testing.T, message string, entities []tg.MessageEntityClass, scheme, base string) []tg.MessageEntityClass {
t.Helper()
appLinks, err := links.NewAppLinkBuilder(scheme, base)
if err != nil {
t.Fatal(err)
}
return augmentAutoEntities(message, entities, appLinks)
}
func TestFirstPreviewableURL(t *testing.T) {
t.Run("text-url-entity", func(t *testing.T) {
got, ok := firstPreviewableURL("click here", []tg.MessageEntityClass{
&tg.MessageEntityTextURL{Offset: 0, Length: 5, URL: "https://example.com/a"},
})
if !ok || got != "https://example.com/a" {
t.Fatalf("got (%q,%v)", got, ok)
}
})
t.Run("url-entity-utf16-slice", func(t *testing.T) {
// 含 4 字节 emojiUTF-16 占 2 码元)前缀,验证按 UTF-16 偏移切片正确。
msg := "👍 https://example.com/x done"
// "👍"=2 units, " "=1 → URL 从 offset 3 起,长度 = len16("https://example.com/x")=21。
got, ok := firstPreviewableURL(msg, []tg.MessageEntityClass{
&tg.MessageEntityURL{Offset: 3, Length: 21},
})
if !ok || got != "https://example.com/x" {
t.Fatalf("got (%q,%v), want https://example.com/x", got, ok)
}
})
t.Run("first-of-many", func(t *testing.T) {
got, ok := firstPreviewableURL("a b", []tg.MessageEntityClass{
&tg.MessageEntityBold{Offset: 0, Length: 1},
&tg.MessageEntityTextURL{Offset: 0, Length: 1, URL: "https://first.example/"},
&tg.MessageEntityTextURL{Offset: 2, Length: 1, URL: "https://second.example/"},
})
if !ok || got != "https://first.example/" {
t.Fatalf("got (%q,%v)", got, ok)
}
})
t.Run("raw-text-fallback-no-entities", func(t *testing.T) {
// TDesktop 不带 url 实体,依赖服务端扫原始文本。
got, ok := firstPreviewableURL("check https://example.com/x bare text", nil)
if !ok || got != "https://example.com/x" {
t.Fatalf("raw-text fallback got (%q,%v), want https://example.com/x", got, ok)
}
})
t.Run("raw-text-trim-trailing-punct", func(t *testing.T) {
got, ok := firstPreviewableURL("见 https://example.com/x。", nil)
if !ok || got != "https://example.com/x" {
t.Fatalf("trailing punct trim got (%q,%v)", got, ok)
}
})
t.Run("raw-text-bare-url", func(t *testing.T) {
got, ok := firstPreviewableURL("https://github.com/golang/go", nil)
if !ok || got != "https://github.com/golang/go" {
t.Fatalf("bare url got (%q,%v)", got, ok)
}
})
t.Run("no-url-no-extract", func(t *testing.T) {
if got, ok := firstPreviewableURL("plain text without any link", nil); ok {
t.Fatalf("text without url should not extract, got %q", got)
}
})
t.Run("non-http-entity-skipped", func(t *testing.T) {
if _, ok := firstPreviewableURL("x", []tg.MessageEntityClass{
&tg.MessageEntityTextURL{Offset: 0, Length: 1, URL: "ftp://example.com/x"},
}); ok {
t.Fatalf("ftp URL should be rejected")
}
})
t.Run("custom-scheme-entity-does-not-preview", func(t *testing.T) {
message := "telesrv://resolve?domain=Alice"
entities := testAugmentAutoEntities(message, nil)
if len(entities) != 1 {
t.Fatalf("entities = %d, want 1", len(entities))
}
if got, ok := firstPreviewableURL(message, entities); ok {
t.Fatalf("custom app-link must not become a webpage preview, got %q", got)
}
})
t.Run("custom-scheme-before-http-still-previews-http", func(t *testing.T) {
message := "telesrv://resolve?domain=Alice then https://example.com/x"
entities := testAugmentAutoEntities(message, nil)
got, ok := firstPreviewableURL(message, entities)
if !ok || got != "https://example.com/x" {
t.Fatalf("got (%q,%v), want https://example.com/x", got, ok)
}
})
}
func urlEntity(t *testing.T, e tg.MessageEntityClass) *tg.MessageEntityURL {
t.Helper()
u, ok := e.(*tg.MessageEntityURL)
if !ok {
t.Fatalf("entity = %T, want *tg.MessageEntityURL", e)
}
return u
}
// TestAugmentAutoEntitiesURL 验证服务端在客户端未带 url 实体时检测文本链接补 MessageEntityURL
// 高亮UTF-16 偏移正确,多链接全检测,客户端已带 url 实体则不重复。
func TestAugmentAutoEntitiesURL(t *testing.T) {
t.Run("detect-when-no-client-entities", func(t *testing.T) {
got := testAugmentAutoEntities("see https://example.com/x now", nil)
if len(got) != 1 {
t.Fatalf("entities = %d, want 1", len(got))
}
e := urlEntity(t, got[0])
if e.Offset != 4 || e.Length != 21 {
t.Errorf("offset/length = %d/%d, want 4/21", e.Offset, e.Length)
}
})
t.Run("utf16-offset-with-emoji", func(t *testing.T) {
got := testAugmentAutoEntities("\U0001f44d https://x", nil) // 👍=2 units, space=1 → url at 3
if len(got) != 1 {
t.Fatalf("entities = %d, want 1", len(got))
}
if e := urlEntity(t, got[0]); e.Offset != 3 || e.Length != 9 {
t.Errorf("offset/length = %d/%d, want 3/9", e.Offset, e.Length)
}
})
t.Run("multiple-urls", func(t *testing.T) {
if got := testAugmentAutoEntities("https://a.com and https://b.com", nil); len(got) != 2 {
t.Fatalf("entities = %d, want 2", len(got))
}
})
t.Run("respect-client-url-entity", func(t *testing.T) {
ents := []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: 0, Length: 9}}
if got := testAugmentAutoEntities("https://x more https://y", ents); len(got) != 2 {
t.Fatalf("server should fill the missing URL span, got %d", len(got))
}
})
t.Run("trailing-punct-not-in-entity", func(t *testing.T) {
got := testAugmentAutoEntities("见 https://example.com。", nil) // 见 ...。
e := urlEntity(t, got[0])
if e.Length != utf16CodeUnitLen("https://example.com") {
t.Errorf("length = %d, want %d (trailing 。 excluded)", e.Length, utf16CodeUnitLen("https://example.com"))
}
})
t.Run("no-url-no-entities", func(t *testing.T) {
if got := testAugmentAutoEntities("plain text", nil); len(got) != 0 {
t.Fatalf("entities = %d, want 0", len(got))
}
})
t.Run("configured-custom-scheme", func(t *testing.T) {
message := "👍 TELESRV://resolve?domain=Alice。"
got := testAugmentAutoEntities(message, nil)
if len(got) != 1 {
t.Fatalf("entities = %d, want 1", len(got))
}
e := urlEntity(t, got[0])
wantURL := "TELESRV://resolve?domain=Alice"
if e.Offset != 3 || e.Length != utf16CodeUnitLen(wantURL) {
t.Fatalf("offset/length = %d/%d, want 3/%d", e.Offset, e.Length, utf16CodeUnitLen(wantURL))
}
})
t.Run("mixed-client-http-and-missing-custom-scheme", func(t *testing.T) {
message := "https://x telesrv://resolve?domain=Alice"
client := []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: 0, Length: utf16CodeUnitLen("https://x")}}
got := testAugmentAutoEntities(message, client)
if len(got) != 2 {
t.Fatalf("entities = %d, want client HTTP plus server app-link", len(got))
}
e := urlEntity(t, got[1])
if e.Offset != utf16CodeUnitLen("https://x ") || e.Length != utf16CodeUnitLen("telesrv://resolve?domain=Alice") {
t.Fatalf("custom entity offset/length = %d/%d", e.Offset, e.Length)
}
})
t.Run("unconfigured-scheme-rejected", func(t *testing.T) {
if got := testAugmentAutoEntities("foo://resolve telesrv://resolve", nil); len(got) != 1 {
t.Fatalf("entities = %d, want only configured telesrv link", len(got))
}
})
t.Run("host-base-is-exact", func(t *testing.T) {
message := "owpg://links.example.test/Alice owpg://other.example.test/Bob telesrv://resolve?domain=Carol"
got := testAugmentAutoEntitiesWithAppLinks(t, message, nil, "telesrv", "owpg://links.example.test")
if len(got) != 2 {
t.Fatalf("entities = %d, want configured host-base and legacy link", len(got))
}
})
}
// BenchmarkAugmentAutoEntities 量化发送热路径:纯文本(无触发字符)应零分配走快路径短路;
// 含 @mention/#hashtag/url 时才进入检测+分配。
func BenchmarkAugmentAutoEntities(b *testing.B) {
b.Run("plain-text", func(b *testing.B) {
msg := "hey everyone, just wanted to share some thoughts about the meeting today and tomorrow"
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = testAugmentAutoEntities(msg, nil)
}
})
b.Run("with-mention-hashtag-url", func(b *testing.B) {
msg := "hi @alice please check #golang docs at https://example.com/x thanks"
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = testAugmentAutoEntities(msg, nil)
}
})
}
// firstEntityOfType 返回切片里首个匹配类型的实体(找不到则 fail
func mentionAt(t *testing.T, got []tg.MessageEntityClass, off, ln int) {
t.Helper()
for _, e := range got {
if m, ok := e.(*tg.MessageEntityMention); ok && m.Offset == off && m.Length == ln {
return
}
}
t.Fatalf("no MessageEntityMention at offset/length %d/%d in %#v", off, ln, got)
}
// TestAugmentAutoEntitiesMention 验证 @mention/#hashtag/$cashtag/bot command 的服务端检测、
// UTF-16 偏移、边界email 不误判)与区间不重叠(不打进客户端富文本实体内部)。
func TestAugmentAutoEntitiesMention(t *testing.T) {
t.Run("bare-mentions", func(t *testing.T) {
// 对齐官方抓包:纯 "@G0ldenMods\n@NGame_Official" → 两个裸 messageEntityMention含前导 @。
got := testAugmentAutoEntities("@G0ldenMods\n@NGame_Official", nil)
mentionAt(t, got, 0, 11) // @G0ldenMods = 10+1
mentionAt(t, got, 12, 15) // @NGame_Official = 14+1
})
t.Run("mention-mid-text", func(t *testing.T) {
got := testAugmentAutoEntities("hi @alice see you", nil)
mentionAt(t, got, 3, 6) // @alice
})
t.Run("email-not-a-mention", func(t *testing.T) {
for _, e := range testAugmentAutoEntities("mail me at bob@example.com please", nil) {
if _, ok := e.(*tg.MessageEntityMention); ok {
t.Fatalf("email local@domain must not yield a mention: %#v", e)
}
}
})
t.Run("utf16-offset-with-emoji", func(t *testing.T) {
// 👍(2 units) + space(1) → @bob 起于 offset 3。
got := testAugmentAutoEntities("\U0001f44d @bob", nil)
mentionAt(t, got, 3, 4)
})
t.Run("no-overlap-with-client-entity", func(t *testing.T) {
// 客户端把 "@bob" 区间标成 textUrl[offset 0,len 4)),服务端不得再补 mention。
ents := []tg.MessageEntityClass{&tg.MessageEntityTextURL{Offset: 0, Length: 4, URL: "https://x"}}
for _, e := range testAugmentAutoEntities("@bob", ents) {
if _, ok := e.(*tg.MessageEntityMention); ok {
t.Fatalf("mention must not overlap client entity: %#v", e)
}
}
})
t.Run("no-mention-inside-raw-url-with-client-url-entity", func(t *testing.T) {
// 防回归:客户端带了某个 textUrl 实体hasClientURL=true但未包裹另一条裸 URL
// 裸 URL 路径里的 @scam / #frag 不得被误标成 mention/hashtag钓鱼风险
msg := "Docs see https://t.me/@scam and #promo"
ents := []tg.MessageEntityClass{&tg.MessageEntityTextURL{Offset: 0, Length: 4, URL: "https://x"}}
got := testAugmentAutoEntities(msg, ents)
for _, e := range got {
if m, ok := e.(*tg.MessageEntityMention); ok {
t.Fatalf("mention must not be synthesised inside a raw URL: offset=%d len=%d", m.Offset, m.Length)
}
}
// URL 之外的 #promo 仍应高亮。
var hasPromo bool
for _, e := range got {
if _, ok := e.(*tg.MessageEntityHashtag); ok {
hasPromo = true
}
}
if !hasPromo {
t.Fatalf("#promo outside the URL should still be a hashtag: %#v", got)
}
})
t.Run("hashtag", func(t *testing.T) {
var found bool
for _, e := range testAugmentAutoEntities("love #golang here", nil) {
if h, ok := e.(*tg.MessageEntityHashtag); ok && h.Offset == 5 && h.Length == 7 {
found = true
}
}
if !found {
t.Fatalf("expected #golang hashtag at 5/7")
}
})
t.Run("hashtag-all-digits-skipped", func(t *testing.T) {
for _, e := range testAugmentAutoEntities("number #123 here", nil) {
if _, ok := e.(*tg.MessageEntityHashtag); ok {
t.Fatalf("leading-digit hashtag must be skipped: %#v", e)
}
}
})
t.Run("bot-command", func(t *testing.T) {
var found bool
for _, e := range testAugmentAutoEntities("/start@MyBot now", nil) {
if c, ok := e.(*tg.MessageEntityBotCommand); ok && c.Offset == 0 && c.Length == 12 {
found = true
}
}
if !found {
t.Fatalf("expected /start@MyBot bot command at 0/12")
}
})
t.Run("slash-in-path-not-command", func(t *testing.T) {
for _, e := range testAugmentAutoEntities("see and/or maybe", nil) {
if _, ok := e.(*tg.MessageEntityBotCommand); ok {
t.Fatalf("and/or must not be a bot command: %#v", e)
}
}
})
t.Run("cashtag", func(t *testing.T) {
var found bool
for _, e := range testAugmentAutoEntities("buy $USD now", nil) {
if c, ok := e.(*tg.MessageEntityCashtag); ok && c.Offset == 4 && c.Length == 4 {
found = true
}
}
if !found {
t.Fatalf("expected $USD cashtag at 4/4")
}
})
}