Files
dingtalk-workspace-cli/internal/event/bus/hub_test.go
aa6abc5ed6 feat(event): add personal event subscriptions (#589)
* dws event

* fix consume fail

* test: add stream ticket injection probe

* feat: add portal ticket stream mode

* user event

* fix: allow portal ticket normal without app secret

* event

* user event

* eventType filter

* refactor(event): 优化IPC端点路径处理和改进相关测试

- 用dwsevent.IPCEndpoint替代原先根据GOOS判断的路径逻辑
- 新增event包实现Unix socket路径长度限制及长路径fallback机制
- 添加endpoint_test.go覆盖路径短长及唯一性的单元测试
- 修改busctl模块使用统一的IPC端点获取方法,避免重复实现
- transport_unix.go新增checkSocketPath函数检查路径长度,防止EINVAL错误
- 在监听和连接Unix socket时加入路径限制检查,提升错误明晰度
- 去除多个文件中无用的runtime导入,简化代码依赖

* opt

* event skill

* default value

* install script event

* fix: remove subscribe id event fanout filter

* fix(personal): 修正指定发送人消息描述错误

* more im event

* filter subId

* fix: align personal event schema with stream payload

* fix: avoid duplicate app helper name

* feat: simplify personal event schemas

* feat: simplify event schema output

* docs: refine dingtalk event skill references

* feat: align personal event consume flags

* fix event stop and status visibility

* hide app event public entrypoints

* hide incomplete personal sender event

* remove external event reference comments

* chore(event): prepare official release

* fix(event): harden personal stream lifecycle

---------

Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com>
Co-authored-by: zhengyubai <zhengyubai618@gmail.com>
2026-07-10 17:54:43 +08:00

412 lines
10 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"
"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_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")
}
}