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 verifies the gate is wired into the // ingress (onMessage) and that a rate-limited non-owner message is dropped with the // reason surfaced through the OnRejected audit hook. The dropped path is synchronous // (no worker goroutine is spawned), so the assertion is deterministic. 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) // First message is admitted (async worker), only auditing the second, which the // total per-minute cap refuses. 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 verifies owner messages bypass the flow gate // entirely: even with an aggressive rate limit, an owner message is never dropped as // 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: &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", "groupMsgPerMin": 1, "maxMessages": 1}} b.Add(ad) // Three owner messages: if the gate applied, the 2nd and 3rd would be rate // limited / capped and reach OnRejected. Owner bypass leaves the audit empty. 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 pins the config-to-limit resolution that keeps // old CHANNELS_JSON accounts backward compatible: absent key → off, 0 → protective // default, negative → off, positive → pinned. 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 is a model.Adapter stub returning a fixed completion. 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 verifies the review model is wrapped into a Reviewer that asks // the LLM which queued messages to keep and filters the returned indices. 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) } // Out-of-range index 99 and the duplicate 0 are dropped; keep = [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 verifies a review-model failure is surfaced to the gate, // which then falls back to FIFO eviction rather than fabricating a success. 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") } }