package channelbridge import ( "context" "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/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) func itoa(n int) string { if n == 0 { return "0" } var b [20]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } return string(b[i:]) } func groupSender(i int) adapter.UnifiedMessage { return adapter.UnifiedMessage{ Platform: "kook", ChannelID: "-100", ChannelType: adapter.ChannelGroup, GroupID: "-100", SenderID: "u" + itoa(i), Content: "spam " + itoa(i), ContentType: adapter.ContentText, } } func groupOwnerMsg(i int) adapter.UnifiedMessage { m := groupSender(i) m.Sender = adapter.Actor{Role: adapter.RoleOwner} return m } // TestFlowGate_RateLimitDropsNonOwnerAndAudits 验证闸门已接入 // 入口(onMessage),且被限流的非主人消息会被丢弃并经 // OnRejected 审计钩子暴露原因。丢弃路径是同步的 // (不会派生工作 goroutine),因此断言是确定性的。 func TestFlowGate_RateLimitDropsNonOwnerAndAudits(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "hi"}} sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}} var rejected []string b, err := New(Options{ Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) }, }) if err != nil { t.Fatalf("New: %v", err) } ad := &fakeAdapter{name: "kook", cfg: map[string]any{"bot_token": "x", "msgPerMin": 1}} b.Add(ad) // 第一条消息被放行(异步工作项),只对第二条入审计, // 它被每分钟总量上限拒绝。 b.onMessage(context.Background(), groupSender(0), ad) b.onMessage(context.Background(), groupSender(1), ad) if len(rejected) != 1 || rejected[0] != "rate_limited" { t.Fatalf("rejected = %v, want [rate_limited]", rejected) } } // TestFlowGate_OwnerPrivateBypassesGate 验证主人消息完全绕过流控 // 闸门:即使限流很激进,主人消息也绝不会以 // rate_limited / review_pruned / capped 被丢弃。 func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "hi"}} sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}} var rejected []string b, err := New(Options{ Loop: chat, Sessions: sess, Owner: testOwnerProfile("kook", "u0", "u1", "u2"), OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) }, }) if err != nil { t.Fatalf("New: %v", err) } ad := &fakeAdapter{name: "kook", cfg: map[string]any{"bot_token": "x", "groupMsgPerMin": 1, "maxMessages": 1}} b.Add(ad) // 三条主人消息:若闸门生效,第 2、3 条会被限流 // / 触及上限并到达 OnRejected。主人绕过则审计为空。 for i := 0; i < 3; i++ { b.onMessage(context.Background(), groupOwnerMsg(i), ad) } if len(rejected) != 0 { t.Fatalf("owner messages must bypass the gate, got rejected %v", rejected) } } // TestFlowGate_GateOptionsDefaults 钉住「配置→限制」的解析规则, // 它保证旧的 CHANNELS_JSON 账号向后兼容:键缺失 → 关闭,0 → 保护性 // 默认值,负值 → 关闭,正值 → 固定为该值。 func TestFlowGate_GateOptionsDefaults(t *testing.T) { t.Parallel() b, err := New(Options{Loop: &stubChatter{}, Sessions: &stubSessions{}}) if err != nil { t.Fatalf("New: %v", err) } cases := []struct { name string cfg map[string]any group, max int }{ {"absent keys off", map[string]any{"bot_token": "x"}, 0, 0}, {"group 0 defaults 6", map[string]any{"bot_token": "x", "groupMsgPerMin": 0}, 6, 0}, {"group -1 off", map[string]any{"bot_token": "x", "groupMsgPerMin": -1}, 0, 0}, {"group 10 pinned", map[string]any{"bot_token": "x", "groupMsgPerMin": 10}, 10, 0}, {"max 0 defaults 50", map[string]any{"bot_token": "x", "maxMessages": 0}, 0, 50}, {"max -1 off", map[string]any{"bot_token": "x", "maxMessages": -1}, 0, 0}, {"max 80 pinned", map[string]any{"bot_token": "x", "maxMessages": 80}, 0, 80}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { opts := b.gateOptions(&fakeAdapter{name: "kook", cfg: c.cfg}) if opts.GroupMsgPerMin != c.group { t.Fatalf("GroupMsgPerMin = %d, want %d", opts.GroupMsgPerMin, c.group) } if opts.MaxMessages != c.max { t.Fatalf("MaxMessages = %d, want %d", opts.MaxMessages, c.max) } }) } } // mockLLM 是返回固定补全的 model.Adapter 桩。 type mockLLM struct { content string err error } func (m *mockLLM) Name() string { return "mock-llm" } func (m *mockLLM) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) { if m.err != nil { return model.Response{}, m.err } return model.Response{Content: m.content, Message: model.TextMessage(model.RoleAssistant, m.content)}, nil } func (m *mockLLM) ChatStream(_ context.Context, _ model.Request, _ func(model.StreamEvent)) (model.Response, error) { return model.Response{}, nil } // TestNewLLMReviewer 验证审查模型被包装成 Reviewer:向 // LLM 询问哪些队列消息该保留,并过滤返回的索引。 func TestNewLLMReviewer(t *testing.T) { t.Parallel() llm := &mockLLM{content: `[0,1,99,0]`} review := NewLLMReviewer(llm, nil) msgs := []adapter.UnifiedMessage{ {SenderID: "a", Content: "x", ContentType: adapter.ContentText}, {SenderID: "b", Content: "y", ContentType: adapter.ContentText}, {SenderID: "c", Content: "z", ContentType: adapter.ContentText}, } keep, err := review(context.Background(), msgs) if err != nil { t.Fatalf("review: %v", err) } // 越界索引 99 与重复的 0 被丢弃;保留 = [0,1]。 if len(keep) != 2 || keep[0] != 0 || keep[1] != 1 { t.Fatalf("keep = %v, want [0 1]", keep) } if llm.err != nil { t.Fatal("unexpected mock error") } } // TestNewLLMReviewerError 验证审查模型失败会暴露给闸门, // 闸门随后回退为 FIFO 淘汰,而不是伪造一个成功。 func TestNewLLMReviewerError(t *testing.T) { t.Parallel() review := NewLLMReviewer(&mockLLM{err: context.DeadlineExceeded}, nil) if _, err := review(context.Background(), []adapter.UnifiedMessage{ {SenderID: "a", Content: "x", ContentType: adapter.ContentText}, }); err == nil { t.Fatal("expected error from failed review model") } } func TestParseKeepIndices(t *testing.T) { t.Parallel() got, err := parseKeepIndices(`[2,0,0,5]`, 3) if err != nil { t.Fatalf("parse: %v", err) } if len(got) != 2 || got[0] != 0 || got[1] != 2 { t.Fatalf("keep = %v, want [0 2] (out-of-range/dup dropped)", got) } if _, err := parseKeepIndices(`not json`, 3); err == nil { t.Fatal("expected error for non-JSON") } }