package channelbridge // 本文件承载 **IM-1 × M1 的跨层端到端用例**:让**真的 Telegram 适配器**在入站时把 // 平台文件取回并内联,再把那条消息喂进**真的桥接入站流水线**(真 store + 真抽取器 + // 真循环 + 假模型,夹具沿用 inbound_documents_test.go),断言模型可见视图里出现该附件 // 的清单行与正文 —— 即任务书 IM-1 §4-1 要求的「与 M1 既有集成用例同形状」。 // // 另含本切片的红线:正文里点名的任意 URL **绝不**被取回(按路径分别断言命中次数)。 import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram" ) // e2eAssetServer 是同时充当「Telegram Bot API」与「文件服务端」的假服务端:它按 // 路径分别计数,使「附件取回 1 次、正文里的 URL 0 次」可以分别断言。 type e2eAssetServer struct { srv *httptest.Server docHits atomic.Int32 bodyHits atomic.Int32 docBody string } func newE2EAssetServer(t *testing.T, docBody string) *e2eAssetServer { t.Helper() s := &e2eAssetServer{docBody: docBody} s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/getFile"): okResponseJSON(w, map[string]any{ "file_id": "doc-id", "file_path": "documents/report.txt", "file_size": len(docBody), }) case strings.Contains(r.URL.Path, "/file/bot") && strings.HasSuffix(r.URL.Path, "documents/report.txt"): s.docHits.Add(1) w.Header().Set("Content-Type", "text/plain") _, _ = w.Write([]byte(docBody)) case strings.Contains(r.URL.Path, "/file/bot"): // 正文里点名的那个 URL 打到这里:它**必须** 0 次命中。 s.bodyHits.Add(1) w.Header().Set("Content-Type", "text/plain") _, _ = w.Write([]byte("绝不该被取回")) default: w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(s.srv.Close) return s } // okResponseJSON 写一个 Bot API 成功信封。 func okResponseJSON(w http.ResponseWriter, result any) { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result}) } // e2eUpdate 构造一条主人(chat/from = 100)的文档消息,正文里**额外点名**一个 URL。 func e2eUpdate(t *testing.T, fileName, text string) []byte { t.Helper() raw, err := json.Marshal(map[string]any{ "update_id": 1, "message": map[string]any{ "message_id": 42, "date": 1700000000, "chat": map[string]any{"id": 100, "type": "private"}, "from": map[string]any{"id": 100, "first_name": "主人"}, "text": text, "document": map[string]any{ "file_id": "doc-id", "file_name": fileName, "file_size": 64, "mime_type": "text/plain", }, }, }) if err != nil { t.Fatalf("marshal update: %v", err) } return raw } // TestInboundAdapterInlineReachesModelView 是切片 IM-1 的主验收用例:真适配器取回 // 字节 → 桥接摄取 → 抽取 → **模型可见视图里出现清单行与正文**。 func TestInboundAdapterInlineReachesModelView(t *testing.T) { t.Parallel() env := newIngestEnv(t, documents.StoreConfig{}, true) body := "第一行:IM 附件正文(IM-1 端到端)\n第二行:必须真的进上下文\n" s := newE2EAssetServer(t, body) // 真适配器:Token/BaseURL/Client 都指向假服务端,日志丢弃。 ad := telegram.New( telegram.Config{BotToken: "123456:TESTTOKEN"}, telegram.WithBaseURL(s.srv.URL), telegram.WithHTTPClient(s.srv.Client()), telegram.WithLogger(slog.New(slog.NewTextHandler(discardWriter{}, nil))), ) var delivered []adapter.UnifiedMessage ad.SetMessageHandler(func(_ context.Context, msg adapter.UnifiedMessage) { delivered = append(delivered, msg) }) // 正文里点名一个「附件地址」形态的 URL(假服务端确实能应答它)。 bodyURL := s.srv.URL + "/file/bot123456:TESTTOKEN/documents/body_only.txt" if _, err := ad.ProcessUpdate(context.Background(), e2eUpdate(t, "report.txt", "读一下 "+bodyURL)); err != nil { t.Fatalf("ProcessUpdate: %v", err) } if len(delivered) != 1 { t.Fatalf("投递条数 = %d, want 1", len(delivered)) } if len(delivered[0].Media) != 1 || string(delivered[0].Media[0].Data) != body { t.Fatalf("适配器没有把附件字节内联进来:%+v", delivered[0].Media) } env.bridge.process(context.Background(), delivered[0]) names := docDirNames(t, env.uploads) if len(names) != 1 { t.Fatalf("落盘文件数 = %v, want 1(内联后的附件必须落库)", names) } visible := env.model.visibleText() if !strings.Contains(visible, "[附件 "+names[0]+" · ") { t.Fatalf("模型可见视图里没有附件清单行:%q", visible) } if !strings.Contains(visible, "第二行:必须真的进上下文") { t.Fatalf("模型可见视图里没有附件正文:%q", visible) } if !strings.Contains(visible, "读一下 ") { t.Fatalf("模型可见视图里丢了用户文本:%q", visible) } // 红线:附件本身被取回 1 次,正文里点名的那个 URL 一次都没有。 if got := s.docHits.Load(); got != 1 { t.Fatalf("附件取回次数 = %d, want 1", got) } if got := s.bodyHits.Load(); got != 0 { t.Fatalf("正文里的 URL 被取回 %d 次,want 0", got) } } // TestInboundAdapterUnsupportedFileStaysHonest 钉住「适配器不下载非白名单格式」与 // 「桥接照既有口径给模型诚实提示」在同一条链路上一致。 func TestInboundAdapterUnsupportedFileStaysHonest(t *testing.T) { t.Parallel() env := newIngestEnv(t, documents.StoreConfig{}, true) s := newE2EAssetServer(t, "不该被取回的内容\n") ad := telegram.New( telegram.Config{BotToken: "123456:TESTTOKEN"}, telegram.WithBaseURL(s.srv.URL), telegram.WithHTTPClient(s.srv.Client()), telegram.WithLogger(slog.New(slog.NewTextHandler(discardWriter{}, nil))), ) var delivered []adapter.UnifiedMessage ad.SetMessageHandler(func(_ context.Context, msg adapter.UnifiedMessage) { delivered = append(delivered, msg) }) if _, err := ad.ProcessUpdate(context.Background(), e2eUpdate(t, "payload.exe", "跑一下这个")); err != nil { t.Fatalf("ProcessUpdate: %v", err) } env.bridge.process(context.Background(), delivered[0]) if names := docDirNames(t, env.uploads); len(names) != 0 { t.Fatalf("非白名单格式被落库了:%v", names) } visible := env.model.visibleText() if !strings.Contains(visible, "payload.exe") || !strings.Contains(visible, "无法读取") { t.Fatalf("模型可见视图里没有诚实的拒绝提示:%q", visible) } if got := s.docHits.Load() + s.bodyHits.Load(); got != 0 { t.Fatalf("非白名单格式发起了 %d 次资产请求,want 0", got) } } // discardWriter 是丢弃输出的 io.Writer(用例不关心适配器日志内容)。 type discardWriter struct{} func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }