package mediafetch // 本文件锁定共享内联循环的口径(切片 IM-1):预筛、限额、条数上限、失败不炸。 // 「正文里的任意 URL ⇒ 0 次命中」这条红线在各适配器的用例里断言(只有适配器才知道 // 正文长什么样);这里锁的是「循环只会把**媒体引用**交给 Fetcher」这条结构性事实。 import ( "context" "errors" "io" "log/slog" "strings" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" ) // countingFetcher 记录每次被要求取回的媒体引用(引用原串与次数),使「0 次命中」 // 与「命中次数」都可断言。 type countingFetcher struct { mu sync.Mutex seen []string data []byte err error maxHit int } func (f *countingFetcher) Fetch(_ context.Context, m adapter.Media) ([]byte, error) { f.mu.Lock() f.seen = append(f.seen, m.URL) n := len(f.seen) f.mu.Unlock() if f.maxHit > 0 && n > f.maxHit { return nil, errors.New("超过了本用例允许的取回次数") } if f.err != nil { return nil, f.err } return f.data, nil } func (f *countingFetcher) calls() int { f.mu.Lock() defer f.mu.Unlock() return len(f.seen) } // testLogger 返回一个丢弃输出的带来源日志器:用例不关心内容,只保证路径不 panic。 func testLogger(t *testing.T) *slog.Logger { t.Helper() return logger.WithSource(logger.New(logger.Options{Out: io.Discard}), "internal/channel/mediafetch") } // txtMessage 构造一条「带一个 .txt 附件」的入站消息。 func txtMessage(url string) *adapter.UnifiedMessage { return &adapter.UnifiedMessage{ Platform: "test", ContentType: adapter.ContentFile, Content: "看看这个文件 " + url, // 正文里也带同一个 URL:正文**不参与**取回决策 Media: []adapter.Media{{ Kind: adapter.MediaFile, URL: url, Name: "notes.txt", MIMEType: "text/plain", Size: 12, }}, } } // TestInlineDownloadsAllowedFile 是正向用例:白名单格式的文件附件被内联进 Data。 func TestInlineDownloadsAllowedFile(t *testing.T) { t.Parallel() f := &countingFetcher{data: []byte("hello\n")} msg := txtMessage("https://cdn.example.com/notes.txt") Inline(context.Background(), testLogger(t), msg, f) if f.calls() != 1 { t.Fatalf("取回次数 = %d, want 1", f.calls()) } if string(msg.Media[0].Data) != "hello\n" { t.Fatalf("Data = %q, want 取回的字节", msg.Media[0].Data) } } // TestInlineNeverFetchesFromBody 钉住结构性事实:循环只把 `Media[].URL` 交给 // Fetcher —— 正文里那个 URL 永远不是取回对象(它是同一条文本,但只作为字符串存在)。 func TestInlineNeverFetchesFromBody(t *testing.T) { t.Parallel() f := &countingFetcher{data: []byte("ok")} msg := txtMessage("https://cdn.example.com/notes.txt") bodyURL := "https://evil.example.com/secret.txt" msg.Content = "读一下 " + bodyURL + " 和 " + msg.Media[0].URL msg.Media = nil // 只有正文带 URL、没有任何文件媒体 Inline(context.Background(), testLogger(t), msg, f) if f.calls() != 0 { t.Fatalf("正文里的 URL 被取回了(%v),want 0 次", f.seen) } } // TestInlineSkipsUnsupportedExtensionWithoutFetch 钉住预筛:非白名单格式**不下载**。 func TestInlineSkipsUnsupportedExtensionWithoutFetch(t *testing.T) { t.Parallel() for _, name := range []string{"payload.exe", "script.js", "page.html", "archive.zip", ""} { t.Run(name, func(t *testing.T) { t.Parallel() f := &countingFetcher{data: []byte("x")} msg := txtMessage("https://cdn.example.com/whatever") msg.Media[0].Name = name Inline(context.Background(), testLogger(t), msg, f) if f.calls() != 0 { t.Fatalf("格式 %q 不在白名单里却发起了取回", name) } if len(msg.Media[0].Data) != 0 { t.Fatalf("格式 %q 不该被内联", name) } }) } } // TestInlineSkipsDeclaredOversizeWithoutFetch 钉住「平台声明的体积超上限时连请求都 // 不发」(省一次必然失败的大传输)。 func TestInlineSkipsDeclaredOversizeWithoutFetch(t *testing.T) { t.Parallel() f := &countingFetcher{data: []byte("x")} msg := txtMessage("https://cdn.example.com/big.txt") msg.Media[0].Size = documents.DefaultMaxBytes + 1 Inline(context.Background(), testLogger(t), msg, f) if f.calls() != 0 { t.Fatalf("声明超限的附件仍被取回 %d 次,want 0", f.calls()) } } // TestInlineFailureKeepsMessageAndContinues 钉住「失败不炸」:一份取不到不阻断其余 // 附件,也不改动消息的其他字段。 func TestInlineFailureKeepsMessageAndContinues(t *testing.T) { t.Parallel() f := &countingFetcher{err: ErrBadStatus} msg := txtMessage("https://cdn.example.com/a.txt") msg.Media = append(msg.Media, adapter.Media{ Kind: adapter.MediaFile, URL: "https://cdn.example.com/b.txt", Name: "b.txt", }) Inline(context.Background(), testLogger(t), msg, f) if f.calls() != 2 { t.Fatalf("取回次数 = %d, want 2(一次失败不阻断第二次)", f.calls()) } for i, m := range msg.Media { if len(m.Data) != 0 { t.Fatalf("Media[%d] 在失败时被写入了字节", i) } } if !strings.Contains(msg.Content, "https://cdn.example.com/a.txt") { t.Fatalf("失败路径改动了正文:%q", msg.Content) } } // TestInlineCapsAttachmentsPerMessage 钉住条数上限与 Web 侧同源(documents.MaxPerMessage)。 func TestInlineCapsAttachmentsPerMessage(t *testing.T) { t.Parallel() f := &countingFetcher{data: []byte("x")} msg := txtMessage("https://cdn.example.com/0.txt") for i := 1; i <= documents.MaxPerMessage+2; i++ { msg.Media = append(msg.Media, adapter.Media{ Kind: adapter.MediaFile, URL: "https://cdn.example.com/x", Name: "x.txt", }) } Inline(context.Background(), testLogger(t), msg, f) if f.calls() != documents.MaxPerMessage { t.Fatalf("取回次数 = %d, want %d(与 Web 侧同一上限)", f.calls(), documents.MaxPerMessage) } } // TestInlineSkipsMediaThatAlreadyHasBytes 钉住「已有字节不重复下载」(本地产出的 // 附件照原样通过)。 func TestInlineSkipsMediaThatAlreadyHasBytes(t *testing.T) { t.Parallel() f := &countingFetcher{data: []byte("new")} msg := txtMessage("https://cdn.example.com/a.txt") msg.Media[0].Data = []byte("local") Inline(context.Background(), testLogger(t), msg, f) if f.calls() != 0 { t.Fatalf("已有字节的附件被重复下载 %d 次", f.calls()) } if string(msg.Media[0].Data) != "local" { t.Fatalf("本地字节被覆盖:%q", msg.Media[0].Data) } } // TestInlineIgnoresNonImageAndNonAudioVoice 钉住分支边界:**声明不是图片格式**的图片媒体 // (这里是 `.txt`)与格式不可转写的语音一样**不取字节**(切片 IM-IMG 起,声明命中图片白名单 // 的图片**会**被 `inlineImages` 取回,见 inline_image_test.go;音频白名单里的语音同理见 // inline_voice_test.go)。 func TestInlineIgnoresNonImageAndNonAudioVoice(t *testing.T) { t.Parallel() f := &countingFetcher{data: []byte("x")} msg := &adapter.UnifiedMessage{ Platform: "test", ContentType: adapter.ContentImage, Media: []adapter.Media{ {Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.txt", Name: "a.txt"}, {Kind: adapter.MediaVoice, URL: "https://cdn.example.com/b.txt", Name: "b.txt"}, }, } Inline(context.Background(), testLogger(t), msg, f) if f.calls() != 0 { t.Fatalf("声明不是图片格式的媒体被取回了 %d 次,want 0", f.calls()) } } // TestDisplayNameFallsBackToURLPath 钉住缺名时的兜底:从资产地址路径末段取扩展名。 func TestDisplayNameFallsBackToURLPath(t *testing.T) { t.Parallel() cases := []struct { name string url string want string }{ {"文件名优先", "https://cdn.example.com/download?id=1", "report.csv"}, {"无名字取路径末段", "https://cdn.example.com/2024/report.csv", "report.csv"}, {"路径无末段则空", "https://cdn.example.com/", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() m := adapter.Media{Name: "", URL: tc.url} if tc.name == "文件名优先" { m.Name = "report.csv" } if got := displayName(m); got != tc.want { t.Fatalf("displayName = %q, want %q", got, tc.want) } }) } }