package channelbridge // 本文件是**跨层整链**端到端:IM 入站带文件 → 提取/回写落库 → 网关 HTTP 读接口。 // // 既有两条用例各据一半:inbound_documents_persist_test.go 断言桥接器把引用写进了 // 落库消息的 `document` 列(用记录型 history 替身),网关侧 session_document_e2e_test.go // 断言读接口把该列投影成 `documents`(但走的是 Web /chat 写入路径)。两者都没有真正跨过 // 「渠道桥 → 网关」这条边界 —— 而安卓报的 P0(消息气泡要显示「这条消息带了哪些文件」) // 期望面正是 `GET /api/v1/sessions/{id}/messages` 的 `documents`。 // // 本用例用**同一个真实 history.Store** 同时喂进桥接器与网关,把一个真 IM 入站回合走完整条 // 链路,最后从读接口的原始 JSON 上逐字读回附件引用;并同时钉住反向边界(无附件 ⇒ 整键缺席)。 import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "unicode/utf8" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" ) // gatewayDocSecret 是本用例签 token 用的 JWT 密钥(仅测试用,与网关侧测试同口径)。 const gatewayDocSecret = "0123456789abcdef0123456789abcdef" // openGatewayDocStore 在隔离的内存 SQLite 上建一个真实 history 存储(绝不碰 // data/cyrene.db),并把**同一个句柄**返回给桥接器与网关共用 —— 这正是被验证的那条边界: // 桥接器写进去的那一列,就是读接口要投影出来的那一列。 func openGatewayDocStore(t *testing.T, log *slog.Logger) *history.Store { t.Helper() ctx := context.Background() conn, err := db.Open(ctx, ":memory:", log) if err != nil { t.Fatalf("打开内存库: %v", err) } t.Cleanup(func() { _ = conn.Close() }) st, err := history.NewStore(ctx, conn, log) if err != nil { t.Fatalf("构造 history 存储: %v", err) } return st } // newGatewayDocEnv 装配整链环境:真存储 + 真循环 + 真抽取器 + 假模型 → 渠道桥; // 同一个存储再喂进真网关并起一个 httptest 服务端。返回桥接器、上传目录、服务端与主人 token。 func newGatewayDocEnv(t *testing.T) (*Bridge, string, *httptest.Server, string) { t.Helper() dataDir := t.TempDir() uploads := filepath.Join(dataDir, "uploads", "documents") log := slog.New(slog.NewTextHandler(&safeBuffer{}, nil)) st := openGatewayDocStore(t, log) // 真循环 + 真抽取器 + 假模型:抽取、注入、结论回三件事都走生产路径。 modelAdapter := &stubModelAdapter{} ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(modelAdapter, nil)) if err != nil { t.Fatalf("构造 agent: %v", err) } runner := loop.New(loop.Options{ Agent: ag, DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads, Log: log}), MaxRounds: 4, Log: log, }) b, err := New(Options{ Log: log, Loop: runner, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}, Owner: testOwnerProfile("telegram", "100"), History: st, DocumentStore: documents.StoreConfig{DataDir: dataDir, UploadsDir: uploads}, }) if err != nil { t.Fatalf("构造桥接器: %v", err) } b.Add(&fakeAdapter{name: "telegram"}) jwt := auth.New(config.AuthConfig{JWTSecret: gatewayDocSecret}) srv, err := gateway.New( &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: gatewayDocSecret}}, gateway.Deps{Auth: jwt, History: st, DataDir: dataDir}, ) if err != nil { t.Fatalf("构造网关: %v", err) } t.Cleanup(srv.Close) ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour) if err != nil { t.Fatalf("签发 token: %v", err) } return b, uploads, ts, tok } // fetchMessages 从读接口取回某会话的消息页,**保持原始 JSON 对象**,使「documents 键 // 缺席」与「documents 是空数组」两种情形能被逐字区分开。 func fetchMessages(t *testing.T, base, sessionID, token string) []map[string]json.RawMessage { t.Helper() req, err := http.NewRequest(http.MethodGet, base+"/api/v1/sessions/"+sessionID+"/messages", nil) if err != nil { t.Fatalf("构造请求: %v", err) } req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("请求读接口: %v", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("读响应体: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("读接口状态 = %d, want 200 (body=%s)", resp.StatusCode, body) } var envelope struct { Data []map[string]json.RawMessage `json:"data"` } if err := json.Unmarshal(body, &envelope); err != nil { t.Fatalf("解码响应: %v (body=%s)", err, body) } return envelope.Data } // TestIMInboundAttachmentVisibleThroughGatewayMessages 是这条跨仓 P0 的整链验收: // 真 IM 入站带文件回合 → 落库/回写 → `GET /api/v1/sessions/{id}/messages` 的那条 // 用户消息必须带 `documents`,且身份(name/mime/size)与结论(status/chars)齐备; // 并且**没有附件**的消息该键整键缺席(不是空数组)。 func TestIMInboundAttachmentVisibleThroughGatewayMessages(t *testing.T) { t.Parallel() b, uploads, ts, tok := newGatewayDocEnv(t) body := []byte("你好,昔涟\n") // ① 一条带 .txt 附件的主人私聊回合;② 一条不带附件的(反向边界)。 b.process(context.Background(), fileMessage("看看这个", "1.txt", body, "text/plain")) b.process(context.Background(), directMessage()) names := docDirNames(t, uploads) if len(names) != 1 { t.Fatalf("落盘文件数 = %v, want 1", names) } name := names[0] msgs := fetchMessages(t, ts.URL, history.MainSessionID, tok) var withAtt, withoutAtt map[string]json.RawMessage for _, m := range msgs { var role, content string _ = json.Unmarshal(m["role"], &role) _ = json.Unmarshal(m["content"], &content) if role != history.RoleUser { continue } switch content { case "看看这个": withAtt = m case "hello": withoutAtt = m } } // 正向:带附件的消息必须带 documents,且身份/结论正确。 if withAtt == nil { t.Fatalf("读接口没返回带附件的那条用户消息: %v", msgs) } raw, ok := withAtt["documents"] if !ok { t.Fatalf("带附件的用户消息缺少 documents 键(这正是安卓报的 P0): %v", withAtt) } var docs []history.DocumentRef if err := json.Unmarshal(raw, &docs); err != nil { t.Fatalf("解码 documents: %v (raw=%s)", err, raw) } if len(docs) != 1 { t.Fatalf("documents 条数 = %d, want 1: %+v", len(docs), docs) } got := docs[0] if got.Name != name || got.MIME != "text/plain" || got.Size != int64(len(body)) { t.Fatalf("documents[0] 身份 = %+v, want {%s text/plain %d}", got, name, len(body)) } if got.Status != history.DocumentStatusExtracted || got.Chars != utf8.RuneCountInString(string(body)) { t.Fatalf("documents[0] 结论 = %+v, want extracted/%d", got, utf8.RuneCountInString(string(body))) } // 反向:无附件的消息该键**整键缺席**(不是空数组 —— 与 omitempty 的既有语义一致)。 if withoutAtt == nil { t.Fatalf("读接口没返回不带附件的那条用户消息: %v", msgs) } if raw, ok := withoutAtt["documents"]; ok { t.Fatalf("无附件消息不该出现 documents 键,got %s: %v", raw, withoutAtt) } }