Files
dingtalk-workspace-cli/internal/event/bus/hub_test.go

513 lines
13 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package bus
import (
"errors"
"fmt"
"sync"
"testing"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func mkEvent(typ, id string) *dwsevent.RawEvent {
return &dwsevent.RawEvent{
EventID: id,
EventType: typ,
Data: `{}`,
ReceivedAt: time.Now().UTC(),
}
}
func drain(c *Consumer, n int, t *testing.T) []*transport.Event {
t.Helper()
out := make([]*transport.Event, 0, n)
for i := 0; i < n; i++ {
select {
case f := <-c.SendCh:
ev, ok := f.(transport.Event)
if !ok {
t.Fatalf("frame %d is not Event: %T", i, f)
}
out = append(out, &ev)
case <-time.After(time.Second):
t.Fatalf("timed out waiting for event %d", i)
}
}
return out
}
func assertNoEvent(c *Consumer, t *testing.T) {
t.Helper()
select {
case f := <-c.SendCh:
t.Fatalf("unexpected frame: %#v", f)
case <-time.After(50 * time.Millisecond):
}
}
func TestHub_RegisterAssignsMonotonicID(t *testing.T) {
h := NewHub(10)
c1, err := h.Register(transport.Hello{ConsumerPID: 1})
if err != nil {
t.Fatal(err)
}
c2, err := h.Register(transport.Hello{ConsumerPID: 2})
if err != nil {
t.Fatal(err)
}
if c1.ID >= c2.ID {
t.Fatalf("IDs not monotonic: %d, %d", c1.ID, c2.ID)
}
}
func TestHub_DeliverMatchesPrefix(t *testing.T) {
h := NewHub(10)
c, err := h.Register(transport.Hello{EventTypes: []string{"im.message.*"}})
if err != nil {
t.Fatal(err)
}
h.Deliver(mkEvent("im.message.receive_v1", "1"))
h.Deliver(mkEvent("approval.task", "2")) // no match
h.Deliver(mkEvent("im.message.at_v1", "3"))
got := drain(c, 2, t)
if got[0].EventID != "1" || got[1].EventID != "3" {
t.Fatalf("expected 1,3 got %s,%s", got[0].EventID, got[1].EventID)
}
if got[0].Seq != 1 || got[1].Seq != 2 {
t.Fatalf("seq mismatch: %d %d", got[0].Seq, got[1].Seq)
}
}
func TestHub_DeliverCatchAll(t *testing.T) {
h := NewHub(10)
c, _ := h.Register(transport.Hello{}) // empty == catch-all
h.Deliver(mkEvent("im.message.receive_v1", "1"))
h.Deliver(mkEvent("approval.task", "2"))
h.Deliver(mkEvent("foo.bar", "3"))
got := drain(c, 3, t)
if got[0].EventID != "1" || got[1].EventID != "2" || got[2].EventID != "3" {
t.Fatalf("catch-all missed events: %+v", got)
}
}
func TestHub_DeliverFilterRegex(t *testing.T) {
h := NewHub(10)
c, err := h.Register(transport.Hello{Filter: `^im\.`})
if err != nil {
t.Fatal(err)
}
h.Deliver(mkEvent("im.message.receive_v1", "1"))
h.Deliver(mkEvent("approval.task", "2")) // filtered out
h.Deliver(mkEvent("im.chat.member.bot.added_v1", "3"))
got := drain(c, 2, t)
if got[0].EventID != "1" || got[1].EventID != "3" {
t.Fatalf("filter regex missed: %+v", got)
}
}
func TestHub_DeliverFiltersBySubscribeID(t *testing.T) {
h := NewHub(10)
c, err := h.Register(transport.Hello{
EventTypes: []string{"user_im_message_receive_o2o"},
SubscribeID: "sub-b",
})
if err != nil {
t.Fatal(err)
}
wrong := mkEvent("user_im_message_receive_o2o", "1")
wrong.SubscribeID = "sub-c"
h.Deliver(wrong)
assertNoEvent(c, t)
right := mkEvent("user_im_message_receive_o2o", "2")
right.SubscribeID = "sub-b"
h.Deliver(right)
got := drain(c, 1, t)
if got[0].EventID != "2" || got[0].SubscribeID != "sub-b" {
t.Fatalf("event = %#v, want sub-b event 2", got[0])
}
}
func TestHub_DeliverSubscribeIDSeparatesSameEventType(t *testing.T) {
h := NewHub(10)
b, err := h.Register(transport.Hello{
EventTypes: []string{"user_im_message_receive_o2o"},
SubscribeID: "sub-b",
})
if err != nil {
t.Fatal(err)
}
c, err := h.Register(transport.Hello{
EventTypes: []string{"user_im_message_receive_o2o"},
SubscribeID: "sub-c",
})
if err != nil {
t.Fatal(err)
}
evB := mkEvent("user_im_message_receive_o2o", "b-msg")
evB.SubscribeID = "sub-b"
evC := mkEvent("user_im_message_receive_o2o", "c-msg")
evC.SubscribeID = "sub-c"
h.Deliver(evB)
h.Deliver(evC)
gotB := drain(b, 1, t)
gotC := drain(c, 1, t)
if gotB[0].EventID != "b-msg" || gotB[0].SubscribeID != "sub-b" {
t.Fatalf("B consumer got %#v", gotB[0])
}
if gotC[0].EventID != "c-msg" || gotC[0].SubscribeID != "sub-c" {
t.Fatalf("C consumer got %#v", gotC[0])
}
assertNoEvent(b, t)
assertNoEvent(c, t)
}
func TestHub_DeliverDropsMissingSubscribeIDForSpecificConsumer(t *testing.T) {
h := NewHub(10)
c, err := h.Register(transport.Hello{
EventTypes: []string{"user_im_message_receive_group"},
SubscribeID: "sub-group",
})
if err != nil {
t.Fatal(err)
}
h.Deliver(mkEvent("user_im_message_receive_group", "missing-sub"))
assertNoEvent(c, t)
}
func TestHub_DeliverEmptyConsumerSubscribeIDReceivesAnySubscribeID(t *testing.T) {
h := NewHub(10)
c, err := h.Register(transport.Hello{
EventTypes: []string{"user_im_message_receive_o2o"},
})
if err != nil {
t.Fatal(err)
}
ev := mkEvent("user_im_message_receive_o2o", "1")
ev.SubscribeID = "sub-any"
h.Deliver(ev)
got := drain(c, 1, t)
if got[0].SubscribeID != "sub-any" {
t.Fatalf("event subscribe_id = %q, want sub-any", got[0].SubscribeID)
}
}
func TestHub_DeliverEventTypeMismatchEvenWithSubscribeID(t *testing.T) {
h := NewHub(10)
c, err := h.Register(transport.Hello{
EventTypes: []string{"user_im_message_receive_o2o"},
SubscribeID: "sub-1",
})
if err != nil {
t.Fatal(err)
}
ev := mkEvent("user_im_message_receive_group", "1")
ev.SubscribeID = "sub-1"
h.Deliver(ev)
assertNoEvent(c, t)
}
func TestHub_RegisterRejectsBadFilterRegex(t *testing.T) {
h := NewHub(10)
_, err := h.Register(transport.Hello{Filter: `(unclosed`})
if err == nil {
t.Fatal("expected RegisterError for bad regex")
}
var re *RegisterError
if !errors.As(err, &re) {
t.Fatalf("err = %v, want *RegisterError", err)
}
}
func TestHub_DropOldestOnFullChannel(t *testing.T) {
h := NewHub(2) // small buffer
c, _ := h.Register(transport.Hello{})
// Push 5 events without draining → 2 stay, 3 dropped
for i := 0; i < 5; i++ {
h.Deliver(mkEvent("foo", string(rune('0'+i))))
}
if c.received.Load() != 2 {
t.Fatalf("received = %d, want 2", c.received.Load())
}
if c.dropped.Load() != 3 {
t.Fatalf("dropped = %d, want 3", c.dropped.Load())
}
// Bus counters reflect it too
snap := h.Counters().Snapshot()
if snap["foo"].Dropped != 3 {
t.Fatalf("hub dropped = %d, want 3", snap["foo"].Dropped)
}
}
func TestHub_UnregisterClosesChannel(t *testing.T) {
h := NewHub(10)
c, _ := h.Register(transport.Hello{})
h.Unregister(c.ID)
// Channel should be closed; receive returns zero value with ok=false
_, ok := <-c.SendCh
if ok {
t.Fatal("SendCh should be closed after Unregister")
}
// Further Deliver must not panic (closed flag prevents send)
h.Deliver(mkEvent("foo", "x"))
if h.Len() != 0 {
t.Fatalf("Len after Unregister = %d, want 0", h.Len())
}
}
func TestHub_UnregisterIdempotent(t *testing.T) {
h := NewHub(10)
c, _ := h.Register(transport.Hello{})
h.Unregister(c.ID)
h.Unregister(c.ID) // must not panic
h.Unregister(9999) // unknown ID
}
func TestHub_StopConsumersTargetsExactSubscribeID(t *testing.T) {
h := NewHub(10)
if stopped := h.StopConsumers([]string{"", " "}, "ignored"); stopped != nil {
t.Fatalf("empty targets stopped = %#v", stopped)
}
a, err := h.Register(transport.Hello{SubscribeID: "sub-a"})
if err != nil {
t.Fatal(err)
}
b, err := h.Register(transport.Hello{SubscribeID: "sub-b"})
if err != nil {
t.Fatal(err)
}
stopped := h.StopConsumers([]string{" sub-a ", "sub-a", "missing"}, "")
if len(stopped) != 1 || stopped[0] != "sub-a" {
t.Fatalf("stopped = %#v, want [sub-a]", stopped)
}
select {
case reason := <-a.StopCh:
if reason != transport.ByeReasonSubscriptionStopped {
t.Fatalf("reason = %q", reason)
}
case <-time.After(time.Second):
t.Fatal("sub-a stop was not signalled")
}
select {
case reason := <-b.StopCh:
t.Fatalf("sub-b was stopped: %q", reason)
case <-time.After(50 * time.Millisecond):
}
}
func TestHub_StopConsumersCoalescesQueuedStop(t *testing.T) {
h := NewHub(10)
c, err := h.Register(transport.Hello{SubscribeID: "sub-a"})
if err != nil {
t.Fatal(err)
}
if got := h.StopConsumers([]string{"sub-a"}, "first"); len(got) != 1 {
t.Fatalf("first stop = %#v", got)
}
if got := h.StopConsumers([]string{"sub-a"}, "second"); len(got) != 1 {
t.Fatalf("second stop = %#v", got)
}
if reason := <-c.StopCh; reason != "first" {
t.Fatalf("queued reason = %q, want first", reason)
}
select {
case reason := <-c.StopCh:
t.Fatalf("duplicate stop queued: %q", reason)
default:
}
}
func TestCrossPlatformCoverageHubStopAllBypassesFullEventBuffer(t *testing.T) {
hub := NewHub(1)
consumer, err := hub.Register(transport.Hello{Type: transport.FrameTypeHello})
if err != nil {
t.Fatal(err)
}
consumer.SendCh <- transport.Event{Type: transport.FrameTypeEvent}
if stopped := hub.StopAll(transport.ByeReasonRuntimeTokenRejected); stopped != 1 {
t.Fatalf("StopAll() = %d, want 1", stopped)
}
select {
case reason := <-consumer.StopCh:
if reason != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("StopCh reason = %q", reason)
}
default:
t.Fatal("terminal stop was blocked behind the full event buffer")
}
}
func TestHub_ConcurrentStopConsumersRegisterUnregister(t *testing.T) {
h := NewHub(4)
const workers = 32
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
subscribeID := fmt.Sprintf("sub-%d", i)
c, err := h.Register(transport.Hello{SubscribeID: subscribeID})
if err != nil {
t.Errorf("register: %v", err)
return
}
h.StopConsumers([]string{subscribeID}, "concurrent-stop")
h.Unregister(c.ID)
}(i)
}
wg.Wait()
if got := h.Len(); got != 0 {
t.Fatalf("remaining consumers = %d", got)
}
}
func TestHub_ConcurrentDeliverBroadcastUnregister(t *testing.T) {
for iteration := 0; iteration < 200; iteration++ {
h := NewHub(4)
c, err := h.Register(transport.Hello{ConsumerPID: iteration + 1})
if err != nil {
t.Fatal(err)
}
start := make(chan struct{})
var wg sync.WaitGroup
for producer := 0; producer < 4; producer++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < 25; i++ {
h.Deliver(mkEvent("foo", "event"))
h.Broadcast(transport.SourceState{Type: transport.FrameTypeSourceState, State: "connected"})
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
<-start
h.Unregister(c.ID)
}()
close(start)
wg.Wait()
deadline := time.After(time.Second)
drain:
for {
select {
case _, ok := <-c.SendCh:
if !ok {
break drain
}
case <-deadline:
t.Fatal("SendCh was not closed after concurrent unregister")
}
}
seq := c.seq.Load()
received := c.received.Load()
dropped := c.dropped.Load()
h.Deliver(mkEvent("foo", "after-close"))
h.Broadcast(transport.Bye{Type: transport.FrameTypeBye})
if c.seq.Load() != seq || c.received.Load() != received || c.dropped.Load() != dropped {
t.Fatal("closed consumer counters changed after unregister")
}
}
}
func TestHub_BroadcastReachesAllConsumers(t *testing.T) {
h := NewHub(10)
a, _ := h.Register(transport.Hello{ConsumerPID: 1})
b, _ := h.Register(transport.Hello{ConsumerPID: 2})
bye := transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"}
if got := h.Broadcast(bye); got != 2 {
t.Fatalf("Broadcast delivered to %d, want 2", got)
}
for _, c := range []*Consumer{a, b} {
select {
case f := <-c.SendCh:
if _, ok := f.(transport.Bye); !ok {
t.Errorf("PID %d got %T, want Bye", c.PID, f)
}
case <-time.After(time.Second):
t.Errorf("PID %d did not receive broadcast", c.PID)
}
}
}
func TestHub_Snapshot_SortedByPID(t *testing.T) {
h := NewHub(10)
for _, pid := range []int{30, 10, 20} {
_, _ = h.Register(transport.Hello{ConsumerPID: pid, EventTypes: []string{"a"}})
}
snap := h.Snapshot()
if len(snap) != 3 || snap[0].PID != 10 || snap[1].PID != 20 || snap[2].PID != 30 {
t.Fatalf("snapshot not sorted: %+v", snap)
}
}
func TestHub_PerConsumerSeqRestartsAtOne(t *testing.T) {
h := NewHub(10)
a, _ := h.Register(transport.Hello{ConsumerPID: 1})
b, _ := h.Register(transport.Hello{ConsumerPID: 2})
h.Deliver(mkEvent("foo", "x"))
h.Deliver(mkEvent("foo", "y"))
ea := drain(a, 2, t)
eb := drain(b, 2, t)
for i, ev := range ea {
if ev.Seq != uint64(i+1) {
t.Errorf("consumer a seq[%d] = %d, want %d", i, ev.Seq, i+1)
}
}
for i, ev := range eb {
if ev.Seq != uint64(i+1) {
t.Errorf("consumer b seq[%d] = %d, want %d", i, ev.Seq, i+1)
}
}
}
func TestHub_NilEventNoOp(t *testing.T) {
h := NewHub(10)
c, _ := h.Register(transport.Hello{})
h.Deliver(nil)
if c.received.Load() != 0 {
t.Fatal("nil event should not increment")
}
}