Files
dingtalk-workspace-cli/internal/event/transport/protocol_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

238 lines
7.0 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 transport
import (
"encoding/json"
"strings"
"testing"
)
// Each frame type is round-tripped through encoding/json then PeekType
// to guard against accidental wire-format changes (the JSON field tags
// double as part of the cross-version protocol contract).
func TestFrameType_StableWireValues(t *testing.T) {
// If any of these strings change we've made a protocol-breaking
// change. The test value list is duplicated here on purpose so a
// reviewer renaming a constant is forced to also update the test.
wants := map[FrameType]string{
FrameTypeHello: "hello",
FrameTypeHelloAck: "hello_ack",
FrameTypeEvent: "event",
FrameTypeHeartbeat: "heartbeat",
FrameTypeSourceState: "source_state",
FrameTypeBye: "bye",
FrameTypeStatusReq: "status_req",
FrameTypeStatusResp: "status_resp",
}
for ft, want := range wants {
if string(ft) != want {
t.Errorf("FrameType wire value drift: %v != %q", ft, want)
}
}
}
func TestHelloRole_StableWireValues(t *testing.T) {
wants := map[HelloRole]string{
HelloRoleConsumer: "",
HelloRoleStatus: "status",
HelloRoleStop: "stop",
}
for r, want := range wants {
if string(r) != want {
t.Errorf("HelloRole wire value drift: %v != %q", r, want)
}
}
}
func roundTrip(t *testing.T, in any, dst any) {
t.Helper()
b, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal %T: %v", in, err)
}
if err := json.Unmarshal(b, dst); err != nil {
t.Fatalf("unmarshal %T: %v", in, err)
}
}
func TestHello_Roundtrip(t *testing.T) {
in := Hello{
Type: FrameTypeHello,
ConsumerPID: 42,
EventTypes: []string{"im.*", "approval.task"},
Filter: `^im\.`,
Compact: true,
Role: HelloRoleStatus,
}
var out Hello
roundTrip(t, in, &out)
if out.Type != in.Type || out.ConsumerPID != in.ConsumerPID || out.Filter != in.Filter ||
out.Compact != in.Compact || out.Role != in.Role || len(out.EventTypes) != len(in.EventTypes) {
t.Fatalf("roundtrip mismatch: %+v != %+v", out, in)
}
}
func TestHello_OmitemptyForDefaults(t *testing.T) {
// Default values should NOT appear in the wire form so old/new readers
// stay tolerant of each other (each new field comes in with its
// zero value by default).
in := Hello{Type: FrameTypeHello, ConsumerPID: 1}
b, _ := json.Marshal(in)
s := string(b)
for _, k := range []string{`"event_types"`, `"filter"`, `"compact"`, `"role"`} {
if strings.Contains(s, k) {
t.Errorf("zero-value field %s leaked into wire form: %s", k, s)
}
}
}
func TestHelloAck_Roundtrip(t *testing.T) {
in := HelloAck{
Type: FrameTypeHelloAck,
BusPID: 12345,
SourceState: "connected",
StateSource: "inferred",
ClientIDSource: "env",
ClientSecretSource: "env",
IdleTimeoutSecs: 300,
}
var out HelloAck
roundTrip(t, in, &out)
if out != in {
t.Fatalf("HelloAck roundtrip: %+v != %+v", out, in)
}
}
func TestEvent_Roundtrip(t *testing.T) {
in := Event{
Type: FrameTypeEvent,
Seq: 7,
EventID: "ev_x",
EventBornTime: 1700000000123,
EventCorpID: "corp_x",
EventType: "im.message.receive_v1",
EventUnifiedAppID: "app_y",
Data: `{"text":"hi"}`,
Headers: map[string]string{"extra": "v"},
ReceivedAtUnixMS: 1700000000999,
}
var out Event
roundTrip(t, in, &out)
if out.EventID != in.EventID || out.Seq != in.Seq || out.Data != in.Data ||
out.Headers["extra"] != "v" {
t.Fatalf("Event roundtrip: %+v != %+v", out, in)
}
}
func TestEvent_OmitsZeroOptionalHeader(t *testing.T) {
in := Event{Type: FrameTypeEvent, EventID: "ev_1", EventType: "x", Data: "{}"}
b, _ := json.Marshal(in)
if strings.Contains(string(b), `"headers"`) {
t.Errorf("nil Headers should not appear: %s", b)
}
}
func TestHeartbeat_Roundtrip(t *testing.T) {
in := Heartbeat{Type: FrameTypeHeartbeat}
var out Heartbeat
roundTrip(t, in, &out)
if out.Type != FrameTypeHeartbeat {
t.Fatalf("Heartbeat type lost: %v", out)
}
}
func TestSourceState_Roundtrip(t *testing.T) {
in := SourceState{
Type: FrameTypeSourceState,
State: "reconnecting",
StateSource: "inferred",
Attempt: 3,
}
var out SourceState
roundTrip(t, in, &out)
if out != in {
t.Fatalf("SourceState mismatch: %+v != %+v", out, in)
}
}
func TestBye_Roundtrip(t *testing.T) {
for _, reason := range []string{"client_done", "shutdown", "idle_timeout", "stop_request"} {
in := Bye{Type: FrameTypeBye, Reason: reason}
var out Bye
roundTrip(t, in, &out)
if out.Reason != reason {
t.Errorf("Bye reason %q lost: %+v", reason, out)
}
}
}
func TestStatusResp_Roundtrip(t *testing.T) {
in := StatusResp{
Type: FrameTypeStatusResp,
Bus: StatusBus{
PID: 99, UptimeSecs: 600, IdleTimeoutSec: 300,
ClientID: "ding_x", Edition: "open",
},
SourceState: StatusSource{
State: "connected", Source: "inferred", ReconnectCount: 1,
LastEventAtMS: 1700000000000, LastReconnectMS: 1700000001000,
},
Consumers: []StatusConsumer{
{PID: 12350, EventTypes: []string{"im.*"}, Filter: `^im\.`,
SubscribedAtMS: 1700000000000, Received: 100, Dropped: 2},
},
PerEventTypeCounters: map[string]Counters{
"im.message.receive_v1": {Received: 100, Dropped: 2},
},
}
var out StatusResp
roundTrip(t, in, &out)
if out.Bus.ClientID != in.Bus.ClientID || len(out.Consumers) != 1 ||
out.PerEventTypeCounters["im.message.receive_v1"].Received != 100 {
t.Fatalf("StatusResp roundtrip mismatch: %+v", out)
}
}
// PeekType is used by the daemon to dispatch incoming frames before
// fully decoding into the typed struct. Behaviour at boundaries matters.
func TestPeekType_EachFrameVariant(t *testing.T) {
cases := []struct {
v any
typ FrameType
}{
{Hello{Type: FrameTypeHello}, FrameTypeHello},
{HelloAck{Type: FrameTypeHelloAck}, FrameTypeHelloAck},
{Event{Type: FrameTypeEvent, EventID: "x"}, FrameTypeEvent},
{Heartbeat{Type: FrameTypeHeartbeat}, FrameTypeHeartbeat},
{SourceState{Type: FrameTypeSourceState, State: "connected"}, FrameTypeSourceState},
{Bye{Type: FrameTypeBye, Reason: "x"}, FrameTypeBye},
{StatusReq{Type: FrameTypeStatusReq}, FrameTypeStatusReq},
{StatusResp{Type: FrameTypeStatusResp}, FrameTypeStatusResp},
}
for _, c := range cases {
b, _ := json.Marshal(c.v)
got, err := PeekType(b)
if err != nil {
t.Errorf("PeekType(%T): %v", c.v, err)
continue
}
if got != c.typ {
t.Errorf("PeekType(%T) = %q, want %q", c.v, got, c.typ)
}
}
}