From e5e5207c941c4446a537281c6df9bd35093fc0c0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 10:11:19 +0800 Subject: [PATCH] =?UTF-8?q?test(channelbridge):=20=E8=A1=A5=E8=B7=A8?= =?UTF-8?q?=E3=80=8C=E6=B8=A0=E9=81=93=E6=A1=A5=20=E2=86=92=20=E7=BD=91?= =?UTF-8?q?=E5=85=B3=E3=80=8D=E7=9A=84=20IM=20=E9=99=84=E4=BB=B6=E6=95=B4?= =?UTF-8?q?=E9=93=BE=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安卓端的 P0(「IM 入站的文件要挂到消息的 documents 上」)其实早已修复 (679cee0):附件引用在落库那一刻写进 chat_messages.document 列,DTO 的 documents 由该列解出。但既有两个用例各据一半(渠道桥侧只验落库、网关侧只验 投影),**没有一条真正跨过这条边界** —— 于是「哪一段断了」只能靠人读代码。 本片补一条真跨边界的端到端:同一个真实 history.Store(:memory: SQLite) 同时喂真渠道桥与真网关,走完整 IM 入站回合(真抽取器 + 真循环 + 假模型), 最后从 HTTP 原始 JSON 逐字断言该消息带 documents。 验收(主会话复跑 + 亲自变异):channelbridge 包测试绿;我把 inboundDocumentColumn 变异成恒回空串(模拟 P0 根因)⇒ 该用例变红,报错文案 正是「带附件的用户消息缺少 documents 键(这正是安卓报的 P0)」;还原后绿。 ⚠️ 真渠道/真设备未跑:要主人发一条新的真文件消息才能做设备级复验。 --- .../inbound_documents_gateway_e2e_test.go | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 internal/services/channelbridge/inbound_documents_gateway_e2e_test.go diff --git a/internal/services/channelbridge/inbound_documents_gateway_e2e_test.go b/internal/services/channelbridge/inbound_documents_gateway_e2e_test.go new file mode 100644 index 0000000..2789b9a --- /dev/null +++ b/internal/services/channelbridge/inbound_documents_gateway_e2e_test.go @@ -0,0 +1,206 @@ +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) + } +}