Files
Cyrene_v2/internal/services/channelbridge/channelbridge_test.go
T
Aska 37e98ec584
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: IM平台列 + deviceID标识 + 会话持久化完善
- agent-96: chat_messages 加 platform 列(迁移0004) + IM主人私聊落主会话web(带platform) + 前端按平台过滤契约 + message_persisted 带 platform(复用 BroadcastPersisted)
- agent-97: chat_messages 加 device_id 列(迁移0005) + history.Message.DeviceID + Web发消息带deviceId(localStorage getClientId) + IM persistToMain 带 deviceID + 前端气泡展示 SourceLabel + 修主会话基础2个bug(fn scanMessage语法/DDL缺列)
- design/16 §4.4(平台) + §4.5(deviceID) 已入档
- 实机: POST /chat 带 deviceId 读回 user/assistant 均带 deviceId+platform; 后端 build/vet/test 绿 + 前端 build 绿
- 红线: 身份靠 actor 元数据; 禁止 shell 读 .env(JWT冒烟由主会话跑)
2026-09-07 04:51:41 +08:00

375 lines
12 KiB
Go

package channelbridge
import (
"context"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"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/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// fakeAdapter is a minimal PlatformAdapter that records outbound sends so the
// bridge's reply path can be asserted without network I/O.
type fakeAdapter struct {
name string
connected bool
sends []adapter.UnifiedMessage
handler adapter.MessageHandler
sendErr error
}
func (f *fakeAdapter) Name() string { return f.name }
func (f *fakeAdapter) Config() map[string]any {
return map[string]any{"bot_token": "configured"}
}
func (f *fakeAdapter) Connect(context.Context) error { f.connected = true; return nil }
func (f *fakeAdapter) Disconnect(context.Context) error {
f.connected = false
return nil
}
func (f *fakeAdapter) IsConnected() bool { return f.connected }
func (f *fakeAdapter) HealthCheck(context.Context) error { return nil }
func (f *fakeAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) {
<-ctx.Done()
}
func (f *fakeAdapter) SendMessage(_ context.Context, resp adapter.UnifiedResponse) error {
for _, m := range resp.Messages {
f.sends = append(f.sends, m)
}
return f.sendErr
}
func (f *fakeAdapter) SendProactive(context.Context, adapter.UnifiedMessage) error { return nil }
func (f *fakeAdapter) SetMessageHandler(fn adapter.MessageHandler) { f.handler = fn }
func (f *fakeAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() }
func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil }
// stubChatter records the session id and messages passed to the agent loop.
type stubChatter struct {
sessionID string
messages []model.Message
res *loop.Result
err error
}
func (s *stubChatter) Run(_ context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) {
s.sessionID = sessionID
s.messages = msgs
return s.res, s.err
}
// stubSessions records the inbound message and returns a fixed session.
type stubSessions struct {
msg adapter.UnifiedMessage
owner *user.OwnerProfile
sess session.Session
err error
}
func (s *stubSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (session.Session, error) {
s.msg = msg
s.owner = owner
if s.err != nil {
return session.Session{}, s.err
}
return s.sess, nil
}
func directMessage() adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram",
ChannelID: "100",
ChannelType: adapter.ChannelDirect,
SenderID: "100",
Content: "hello",
ContentType: adapter.ContentText,
}
}
func newTestBridge(t *testing.T) (*Bridge, *stubChatter, *stubSessions, *fakeAdapter) {
t.Helper()
chat := &stubChatter{res: &loop.Result{Text: "hi back"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
return b, chat, sess, ad
}
func TestProcessRunsPipelineAndReplies(t *testing.T) {
t.Parallel()
b, chat, sess, ad := newTestBridge(t)
ctx := context.Background()
b.process(ctx, directMessage())
if sess.msg.SenderID != "100" {
t.Errorf("resolved sender = %q, want 100", sess.msg.SenderID)
}
// An owner private chat routes into the single main web session (design/16 §4.4).
if chat.sessionID != history.MainSessionID {
t.Errorf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
}
if len(chat.messages) != 1 || chat.messages[0].Role != model.RoleUser {
t.Fatalf("loop messages = %+v, want single user message", chat.messages)
}
if got := chat.messages[0].Content[0].Text; got != "hello" {
t.Errorf("loop text = %q, want hello", got)
}
if len(ad.sends) != 1 {
t.Fatalf("send count = %d, want 1", len(ad.sends))
}
reply := ad.sends[0]
if reply.Content != "hi back" || reply.ContentType != adapter.ContentText {
t.Errorf("reply = %+v, want text 'hi back'", reply)
}
if reply.ChannelID != "100" || reply.Platform != "telegram" {
t.Errorf("reply target = %+v, want platform telegram channel 100", reply)
}
}
func TestProcessSkipsEmptyText(t *testing.T) {
t.Parallel()
b, chat, _, ad := newTestBridge(t)
msg := directMessage()
msg.Content = " "
b.process(context.Background(), msg)
if chat.sessionID != "" {
t.Errorf("loop ran for empty content, session = %q", chat.sessionID)
}
if len(ad.sends) != 0 {
t.Errorf("send count = %d, want 0 for empty content", len(ad.sends))
}
}
func TestProcessLoopErrorDoesNotSend(t *testing.T) {
t.Parallel()
b, chat, _, ad := newTestBridge(t)
chat.res = nil
chat.err = context.DeadlineExceeded
b.process(context.Background(), directMessage())
if chat.sessionID != history.MainSessionID {
t.Errorf("loop should still run for valid text on the main session, session = %q", chat.sessionID)
}
if len(ad.sends) != 0 {
t.Errorf("send count = %d, want 0 on loop error", len(ad.sends))
}
}
func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
t.Parallel()
b, chat, _, ad := newTestBridge(t)
msg := adapter.UnifiedMessage{
Platform: "telegram",
ChannelID: "-20",
ChannelType: adapter.ChannelGroup,
GroupID: "-20",
GroupName: "Gang",
SenderID: "9",
Content: "hello all",
ContentType: adapter.ContentText,
}
chat.res = &loop.Result{Text: "group ack"}
b.process(context.Background(), msg)
if len(ad.sends) != 1 {
t.Fatalf("send count = %d, want 1", len(ad.sends))
}
reply := ad.sends[0]
if reply.ChannelType != adapter.ChannelGroup || reply.GroupID != "-20" {
t.Errorf("group reply target = %+v, want group -20", reply)
}
}
func TestProcessEmptyReplySendsNothing(t *testing.T) {
t.Parallel()
b, chat, _, ad := newTestBridge(t)
chat.res = &loop.Result{Text: " "}
b.process(context.Background(), directMessage())
if len(ad.sends) != 0 {
t.Errorf("send count = %d, want 0 for empty reply", len(ad.sends))
}
}
// captureHistory records every Append for asserting the bridge writes owner-private
// messages into the main web session (design/16 §4.4).
type captureHistory struct {
mu sync.Mutex
appends []history.Message
}
func (c *captureHistory) Append(_ context.Context, msg history.Message) error {
_, err := c.AppendFull(context.Background(), msg)
return err
}
func (c *captureHistory) AppendFull(_ context.Context, msg history.Message) (history.Message, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.appends = append(c.appends, msg)
return msg, nil
}
func (c *captureHistory) List(context.Context, string, int64, int, string) ([]history.Message, error) {
return nil, nil
}
func (c *captureHistory) Recent(context.Context, string, int) ([]history.Message, error) {
return nil, nil
}
func (c *captureHistory) DeleteSession(context.Context, string) error { return nil }
func (c *captureHistory) NewSubSession(context.Context, string) (string, error) {
return "", nil
}
func (c *captureHistory) ListSessions(context.Context) ([]history.SessionMeta, error) {
return nil, nil
}
func (c *captureHistory) records() []history.Message {
c.mu.Lock()
defer c.mu.Unlock()
return append([]history.Message(nil), c.appends...)
}
func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "微信回复"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
hist := &captureHistory{}
var persisted []history.Message
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
History: hist,
OnPersisted: func(m history.Message) { persisted = append(persisted, m) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
msg := directMessage() // Platform = "telegram"
b.process(context.Background(), msg)
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
// Both land on the main web session and carry the channel platform.
if recs[0].SessionID != history.MainSessionID || recs[0].Role != history.RoleUser ||
recs[0].Content != "hello" || recs[0].Platform != "telegram" {
t.Fatalf("user record = %+v, want web/telegram/hello", recs[0])
}
if recs[1].SessionID != history.MainSessionID || recs[1].Role != history.RoleAssistant ||
recs[1].Content != "微信回复" || recs[1].Platform != "telegram" {
t.Fatalf("assistant record = %+v, want web/telegram/微信回复", recs[1])
}
// Every persisted message was handed to the broadcast hook.
if len(persisted) != 2 {
t.Fatalf("persisted notifications = %d, want 2", len(persisted))
}
// And the loop ran against the main session, not the isolated sess-1.
if chat.sessionID != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
}
}
func TestOwnerPrivatePersistsToMainWithDeviceID(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "微信回复"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
History: hist,
})
if err != nil {
t.Fatalf("New: %v", err)
}
// directMessage() → Platform telegram / ChannelID 100 / SenderID 100, so the
// deviceID is built as <platform>:<channelID>:<senderID> (design/16 §4.5).
b.process(context.Background(), directMessage())
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
// Both the user turn and the assistant reply carry the derived deviceID and the
// channel platform, so the Web client can label the source end.
for i, rec := range recs {
if rec.DeviceID != "telegram:100:100" {
t.Fatalf("record[%d] DeviceID = %q, want telegram:100:100", i, rec.DeviceID)
}
if rec.Platform != "telegram" {
t.Fatalf("record[%d] Platform = %q, want telegram", i, rec.Platform)
}
}
if recs[0].Role != history.RoleUser || recs[0].Content != "hello" {
t.Fatalf("user record = %+v", recs[0])
}
if recs[1].Role != history.RoleAssistant || recs[1].Content != "微信回复" {
t.Fatalf("assistant record = %+v", recs[1])
}
}
func TestGroupMessageNotPersistedToMainSession(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "群聊回复"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}}
hist := &captureHistory{}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
msg := adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: "9", Content: "hello all",
ContentType: adapter.ContentText,
}
b.process(context.Background(), msg)
// Group chat stays isolated: nothing is written to the main web session and the
// loop runs against the isolated session id.
if recs := hist.records(); len(recs) != 0 {
t.Fatalf("group appends = %+v, want none persisted", recs)
}
if chat.sessionID != "sess-g" {
t.Fatalf("group loop session id = %q, want sess-g", chat.sessionID)
}
}
func TestNoHistoryStoreKeepsBridgeUnchanged(t *testing.T) {
t.Parallel()
// A bridge built without a history store persists nothing but still runs/replies.
b, chat, _, ad := newTestBridge(t)
b.process(context.Background(), directMessage())
if len(ad.sends) != 1 {
t.Fatalf("send count = %d, want 1", len(ad.sends))
}
if chat.sessionID != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
}
}