test(channelbridge): 补跨「渠道桥 → 网关」的 IM 附件整链回归
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

安卓端的 P0(「IM 入站的文件要挂到消息的 documents 上」)其实早已修复
(679cee0):附件引用在落库那一刻写进 chat_messages.document 列,DTO 的
documents 由该列解出。但既有两个用例各据一半(渠道桥侧只验落库、网关侧只验
投影),**没有一条真正跨过这条边界** —— 于是「哪一段断了」只能靠人读代码。

本片补一条真跨边界的端到端:同一个真实 history.Store(:memory: SQLite)
同时喂真渠道桥与真网关,走完整 IM 入站回合(真抽取器 + 真循环 + 假模型),
最后从 HTTP 原始 JSON 逐字断言该消息带 documents。

验收(主会话复跑 + 亲自变异):channelbridge 包测试绿;我把
inboundDocumentColumn 变异成恒回空串(模拟 P0 根因)⇒ 该用例变红,报错文案
正是「带附件的用户消息缺少 documents 键(这正是安卓报的 P0)」;还原后绿。

⚠️ 真渠道/真设备未跑:要主人发一条新的真文件消息才能做设备级复验。
This commit is contained in:
AskaEth committed 2026-10-07 10:11:19 +08:00
1 parent c46053e276
commit e5e5207c94
1 file changed
+206
@@ -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)
}
}