test(channelbridge): 补跨「渠道桥 → 网关」的 IM 附件整链回归
安卓端的 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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user