Files
Cyrene_v2/internal/services/channelbridge/inbound_documents_gateway_e2e_test.go
AskaEth e5e5207c94
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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)」;还原后绿。

⚠️ 真渠道/真设备未跑:要主人发一条新的真文件消息才能做设备级复验。
2026-10-07 10:11:19 +08:00

207 lines
7.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}