Files
Cyrene_v2/internal/services/channelbridge/flowgate_test.go
T
AskaEth 093f38d986
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/services/channelbridge(纯替换)
- 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避)
- 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净
2026-10-04 13:33:06 +08:00

213 lines
6.9 KiB
Go

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")
}
}