Files
dingtalk-workspace-cli/internal/event/personal/output_test.go

1131 lines
36 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 personal
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func personalMessageData(eventKey string) string {
return fmt.Sprintf(`{
"eventId":"data-event",
"eventKey":%q,
"occurredAtMs":1783483236995,
"subId":"data-sub",
"payload":{
"body":{
"createTime":"2026-07-08 12:00:35",
"sender":"测试用户甲",
"openMessageId":"msg-1",
"senderOpenDingTalkId":"open-user-1",
"openConversationId":"cid-1",
"content":"在吗"
},
"event_time":1783483235983
}
}`, eventKey)
}
func personalReadData(eventKey, messageID, conversationID string) string {
return fmt.Sprintf(`{
"eventId":"read-event",
"eventKey":%q,
"occurredAtMs":1784008412182,
"subId":"read-sub",
"payload":{
"bizid":"internal-bizid",
"body":{
"msgReadTime":"2026-07-14 13:53:31",
"openConversationId":%q,
"openMessageId":%q,
"reader":"测试用户乙",
"readerOpenDingTalkId":"reader-open-id",
"sender":"测试用户甲",
"senderOpenDingTalkId":"sender-open-id"
},
"clientId":"internal-client",
"corpid":"internal-corp",
"event_time":1784008411652,
"filterSubId":"internal-filter",
"uid":100001
}
}`, eventKey, conversationID, messageID)
}
func personalRecallData(eventKey, messageID, conversationID string) string {
return fmt.Sprintf(`{
"eventId":"recall-event",
"eventKey":%q,
"occurredAtMs":1784008592969,
"subId":"recall-sub",
"payload":{
"bizid":"internal-bizid",
"body":{
"msgRecallTime":"2026-07-14 13:56:32",
"openConversationId":%q,
"openMessageId":%q,
"recaller":"测试用户乙",
"recallerOpenDingTalkId":"recaller-open-id",
"sender":"测试用户乙",
"senderOpenDingTalkId":"sender-open-id"
},
"clientId":"internal-client",
"corpid":"internal-corp",
"event_time":1784008592766,
"filterSubId":"internal-filter",
"uid":100001
}
}`, eventKey, conversationID, messageID)
}
func personalReactionData(eventKey, messageID, conversationID string) string {
return fmt.Sprintf(`{
"eventId":"reaction-event",
"eventKey":%q,
"occurredAtMs":1784008680072,
"subId":"reaction-sub",
"payload":{
"bizid":"internal-bizid",
"body":{
"emotionName":"微笑",
"emotionText":"微笑",
"openConversationId":%q,
"openSourceMessageId":%q,
"oper":"测试用户乙",
"operOpenDingtalkId":"operator-open-id",
"operateTime":"2026-07-14 13:57:59",
"operateType":"add",
"sender":"测试用户甲",
"senderOpenDingTalkId":"sender-open-id"
},
"clientId":"internal-client",
"corpid":"internal-corp",
"event_time":1784008679217,
"filterSubId":"internal-filter",
"uid":100001
}
}`, eventKey, conversationID, messageID)
}
func personalGroupMemberData(eventKey string) string {
return fmt.Sprintf(`{
"eventId":"group-member-event",
"eventKey":%q,
"occurredAtMs":1784782513647,
"subId":"group-member-sub",
"payload":{
"uid":100001,
"clientId":"internal-client",
"corpid":"internal-corp",
"bizid":"internal-biz",
"filterSubId":"internal-filter",
"body":{
"operNick":"测试用户甲",
"members":[
{"nick":"测试用户乙","openDingTalkId":"member-open-id-1"},
{"nick":"测试用户丙","openDingTalkId":"member-open-id-2"}
],
"operOpenDingtalkId":"operator-open-id",
"openConversationId":"cid-group-1"
},
"event_time":1784782513502
}
}`, eventKey)
}
func personalOAData(eventKey string) string {
body := map[string]any{
"processInstanceId": "process-instance-1",
"createTime": int64(1785229100000),
"processCode": "PROC-TEST-1",
"title": "测试审批",
}
switch eventKey {
case EventOAApprovalTaskCreated:
body["taskId"] = "approval-task-1"
body["status"] = "RUNNING"
case EventOAApprovalTaskFinished:
body["taskId"] = "approval-task-1"
body["status"] = "FINISHED"
body["result"] = "agree"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalTaskRedirected:
body["taskId"] = "approval-task-1"
body["status"] = "FINISHED"
body["result"] = "redirect"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalInstanceStarted:
body["status"] = "RUNNING"
case EventOAApprovalInstanceCC:
body["status"] = "RUNNING"
case EventOAApprovalInstanceTerminated:
body["status"] = "TERMINATED"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalInstanceFinished:
body["status"] = "FINISHED"
body["result"] = "agree"
body["finishTime"] = int64(1785229199000)
}
data := map[string]any{
"eventId": "oa-event",
"eventKey": eventKey,
"occurredAtMs": int64(1785229200123),
"subId": "oa-data-sub",
"payload": map[string]any{
"uid": 100001,
"CORPID": "internal-corp",
"clientId": "internal-client",
"filterSubId": "internal-filter",
"bizid": "internal-biz",
"orgId": 100002,
"sourceId": "open",
"body": body,
"event_time": int64(1785229199000),
"futureField": map[string]any{"nested": true},
},
}
encoded, _ := json.Marshal(data)
return string(encoded)
}
func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser, EventAllSingleChat, EventAllGroupChat} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
Type: transport.FrameTypeEvent,
EventID: "outer-event",
EventBornTime: 11,
EventType: eventKey,
SubscribeID: "outer-sub",
Data: personalMessageData(eventKey),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got, ok := projected.(MessageEventOutput)
if !ok {
t.Fatalf("ProjectOutput() type = %T", projected)
}
want := MessageEventOutput{
Type: eventKey,
EventID: "data-event",
Timestamp: 1783483236995,
SubscribeID: "outer-sub",
MessageID: "msg-1",
ConversationID: "cid-1",
Sender: "测试用户甲",
SenderOpenDingTalkID: "open-user-1",
Content: "在吗",
CreateTime: "2026-07-08 12:00:35",
EventTime: 1783483235983,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", got, want)
}
encoded, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
for _, absent := range []string{"quoted_message", "forward_messages"} {
if strings.Contains(string(encoded), `"`+absent+`"`) {
t.Fatalf("ordinary message output contains optional field %q: %s", absent, encoded)
}
}
})
}
}
func TestProjectOutputPreservesQuotedMessageContext(t *testing.T) {
data := `{
"eventId":"quoted-event",
"eventKey":"user_im_message_receive_group",
"occurredAtMs":1784792292580,
"subId":"quoted-sub",
"payload":{
"body":{
"createTime":"2026-07-23 15:38:11",
"sender":"郑御白",
"openMessageId":"outer-message",
"senderOpenDingTalkId":"outer-sender-open-id",
"openConversationId":"target-conversation",
"content":"引用回复",
"quotedMessage":{
"createTime":"2026-07-23 15:35:03",
"sender":"null",
"openMessageId":"quoted-message",
"senderOpenDingTalkId":"quoted-sender-open-id",
"openConversationId":"source-conversation",
"content":"被引用的原消息"
}
},
"event_time":1784792291637
}
}`
projected, err := ProjectOutput(transport.Event{
EventType: EventInChat,
SubscribeID: "outer-sub",
Data: data,
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(MessageEventOutput)
want := &MessageEventContext{
MessageID: "quoted-message",
ConversationID: "source-conversation",
Sender: "null",
SenderOpenDingTalkID: "quoted-sender-open-id",
Content: "被引用的原消息",
CreateTime: "2026-07-23 15:35:03",
}
if !reflect.DeepEqual(got.QuotedMessage, want) {
t.Fatalf("quoted_message = %#v, want %#v", got.QuotedMessage, want)
}
if got.ForwardMessages != nil {
t.Fatalf("forward_messages = %#v, want nil", got.ForwardMessages)
}
}
func TestProjectOutputPreservesMergedForwardContextAndMediaLocator(t *testing.T) {
data := `{
"eventId":"forward-event",
"eventKey":"user_im_message_receive_group",
"occurredAtMs":1784861030151,
"subId":"forward-sub",
"payload":{
"body":{
"createTime":"2026-07-24 10:43:49",
"sender":"郑御白",
"openMessageId":"outer-forward-message",
"senderOpenDingTalkId":"outer-sender-open-id",
"openConversationId":"target-conversation",
"content":"Chat history between two users\nUser A:[Image]\nUser A:Forwarded chat record",
"forwardMessages":[
{
"createTime":"2026-07-24 10:33:31",
"sender":"null",
"openMessageId":"image-message",
"senderOpenDingTalkId":"image-sender-open-id",
"openConversationId":"source-conversation",
"content":"[图片消息](mediaId=media-1) 注意:如需下载使用dws chat message download-media命令下载"
},
{
"createTime":"2026-07-24 10:34:46",
"sender":"null",
"openMessageId":"text-message",
"openConversationId":"source-conversation",
"content":"转发聊天记录"
}
]
},
"event_time":1784861029265
}
}`
projected, err := ProjectOutput(transport.Event{
EventType: EventInChat,
SubscribeID: "outer-sub",
Data: data,
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(MessageEventOutput)
want := []MessageEventContext{
{
MessageID: "image-message",
ConversationID: "source-conversation",
Sender: "null",
SenderOpenDingTalkID: "image-sender-open-id",
Content: "[图片消息](mediaId=media-1) 注意:如需下载使用dws chat message download-media命令下载",
CreateTime: "2026-07-24 10:33:31",
},
{
MessageID: "text-message",
ConversationID: "source-conversation",
Sender: "null",
Content: "转发聊天记录",
CreateTime: "2026-07-24 10:34:46",
},
}
if !reflect.DeepEqual(got.ForwardMessages, want) {
t.Fatalf("forward_messages = %#v, want %#v", got.ForwardMessages, want)
}
if got.QuotedMessage != nil {
t.Fatalf("quoted_message = %#v, want nil", got.QuotedMessage)
}
encoded, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
for _, wantFragment := range []string{
`"forward_messages"`,
`"message_id":"image-message"`,
`"conversation_id":"source-conversation"`,
`mediaId=media-1`,
} {
if !strings.Contains(string(encoded), wantFragment) {
t.Fatalf("flattened merged-forward output missing %q: %s", wantFragment, encoded)
}
}
for _, localizedDetection := range []string{"群聊的聊天记录", "的聊天记录"} {
if strings.Contains(got.Content, localizedDetection) {
t.Fatalf("test fixture should not require localized title %q for projection", localizedDetection)
}
}
}
func TestCrossPlatformCoverageProjectOutputGroupLifecycleEvents(t *testing.T) {
for _, eventKey := range []string{EventGroupUpdated, EventGroupDisbanded} {
t.Run(eventKey, func(t *testing.T) {
data := fmt.Sprintf(`{
"eventId":"group-event",
"eventKey":%q,
"occurredAtMs":1784009000000,
"subId":"data-sub",
"payload":{
"uid":100001,
"CORPID":"internal-corp",
"clientId":"internal-client",
"filterSubId":"internal-filter",
"bizid":"internal-biz",
"orgId":100002,
"sourceId":"open",
"event_time":1784008999000,
"body":{
"openConversationId":"cid-group-1",
"title":"测试群新标题",
"operator":{"uid":"business-user-1"}
}
}
}`, eventKey)
projected, err := ProjectOutput(transport.Event{
EventID: "outer-event",
EventType: eventKey,
SubscribeID: "outer-sub",
Data: data,
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got, ok := projected.(GroupLifecycleEventOutput)
if !ok {
t.Fatalf("ProjectOutput() type = %T", projected)
}
if got.Type != eventKey || got.EventID != "group-event" || got.Timestamp != 1784009000000 || got.SubscribeID != "outer-sub" {
t.Fatalf("common fields = %#v", got)
}
for _, internal := range []string{"uid", "CORPID", "clientId", "filterSubId", "bizid", "orgId", "sourceId"} {
if _, ok := got.Payload[internal]; ok {
t.Fatalf("payload retained internal field %q: %#v", internal, got.Payload)
}
}
body, ok := got.Payload["body"].(map[string]any)
if !ok || body["title"] != "测试群新标题" {
t.Fatalf("payload body = %#v", got.Payload["body"])
}
operator := body["operator"].(map[string]any)
if operator["uid"] != "business-user-1" {
t.Fatalf("nested business uid was removed: %#v", operator)
}
})
}
}
func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
tests := []struct {
eventKey string
want any
}{
{
eventKey: EventOAApprovalTaskCreated,
want: OAApprovalTaskCreatedOutput{
Type: EventOAApprovalTaskCreated,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "RUNNING",
CreateTime: 1785229100000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalTaskFinished,
want: OAApprovalTaskFinishedOutput{
Type: EventOAApprovalTaskFinished,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "FINISHED",
Result: "agree",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalTaskRedirected,
want: OAApprovalTaskRedirectedOutput{
Type: EventOAApprovalTaskRedirected,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "FINISHED",
Result: "redirect",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceStarted,
want: OAApprovalInstanceStartedOutput{
Type: EventOAApprovalInstanceStarted,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "RUNNING",
CreateTime: 1785229100000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceCC,
want: OAApprovalInstanceCCOutput{
Type: EventOAApprovalInstanceCC,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "RUNNING",
CreateTime: 1785229100000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceTerminated,
want: OAApprovalInstanceTerminatedOutput{
Type: EventOAApprovalInstanceTerminated,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "TERMINATED",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceFinished,
want: OAApprovalInstanceFinishedOutput{
Type: EventOAApprovalInstanceFinished,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "FINISHED",
Result: "agree",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
}
for _, tt := range tests {
t.Run(tt.eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventID: "outer-event",
EventBornTime: 11,
EventType: tt.eventKey,
SubscribeID: "outer-sub",
Data: personalOAData(tt.eventKey),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
if !reflect.DeepEqual(projected, tt.want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, tt.want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputRejectsUnsupportedOAType(t *testing.T) {
ev := transport.Event{EventID: "outer-event", EventType: "user_oa_approval_unknown"}
projected, err := projectOAApprovalEvent(
ev,
baseEventOutput{Type: ev.EventType, EventID: ev.EventID},
json.RawMessage(`{"body":{"processInstanceId":"process-instance-1"},"event_time":1}`),
)
if err == nil || !strings.Contains(err.Error(), `unsupported personal OA event type "user_oa_approval_unknown"`) {
t.Fatalf("projectOAApprovalEvent() error = %v", err)
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("projectOAApprovalEvent() fallback = %#v, want %#v", projected, ev)
}
}
func TestCrossPlatformCoverageProjectOutputOADecodesDoublyWrappedJSONString(t *testing.T) {
once, err := json.Marshal(personalOAData(EventOAApprovalTaskCreated))
if err != nil {
t.Fatal(err)
}
twice, err := json.Marshal(string(once))
if err != nil {
t.Fatal(err)
}
projected, err := ProjectOutput(transport.Event{Data: string(twice)})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got, ok := projected.(OAApprovalTaskCreatedOutput)
if !ok {
t.Fatalf("ProjectOutput() type = %T, want OAApprovalTaskCreatedOutput", projected)
}
if got.Type != EventOAApprovalTaskCreated || got.EventID != "oa-event" || got.SubscribeID != "oa-data-sub" {
t.Fatalf("ProjectOutput() = %#v", got)
}
}
func TestCrossPlatformCoverageProjectOutputGroupMemberEvents(t *testing.T) {
for _, eventKey := range []string{EventGroupMemberAdded, EventGroupMemberExited} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventID: "outer-event",
EventBornTime: 11,
EventType: eventKey,
SubscribeID: "outer-sub",
Data: personalGroupMemberData(eventKey),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
want := GroupMemberEventOutput{
Type: eventKey,
EventID: "group-member-event",
Timestamp: 1784782513647,
SubscribeID: "outer-sub",
ConversationID: "cid-group-1",
Operator: "测试用户甲",
OperatorOpenDingTalkID: "operator-open-id",
Members: []GroupMemberEventMember{
{Nick: "测试用户乙", OpenDingTalkID: "member-open-id-1"},
{Nick: "测试用户丙", OpenDingTalkID: "member-open-id-2"},
},
EventTime: 1784782513502,
}
if !reflect.DeepEqual(projected, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputGroupMemberAllowsMissingOperator(t *testing.T) {
data := strings.ReplaceAll(personalGroupMemberData(EventGroupMemberExited), `"operNick":"测试用户甲",`, "")
data = strings.ReplaceAll(data, `"operOpenDingtalkId":"operator-open-id",`, "")
projected, err := ProjectOutput(transport.Event{EventType: EventGroupMemberExited, Data: data})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(GroupMemberEventOutput)
if got.Operator != "" || got.OperatorOpenDingTalkID != "" {
t.Fatalf("operator fields = %q/%q, want empty", got.Operator, got.OperatorOpenDingTalkID)
}
if len(got.Members) != 2 {
t.Fatalf("members = %#v, want two members", got.Members)
}
}
func TestCrossPlatformCoverageProjectOutputGroupMemberRejectsLegacyOperatorOpenIDSpellings(t *testing.T) {
for _, legacyField := range []string{"operOpenDingtlkId", "operOpenDingTalkId"} {
t.Run(legacyField, func(t *testing.T) {
data := strings.Replace(personalGroupMemberData(EventGroupMemberAdded), "operOpenDingtalkId", legacyField, 1)
projected, err := ProjectOutput(transport.Event{EventType: EventGroupMemberAdded, Data: data})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(GroupMemberEventOutput)
if got.OperatorOpenDingTalkID != "" {
t.Fatalf("operator_open_dingtalk_id = %q, want empty for legacy field %s", got.OperatorOpenDingTalkID, legacyField)
}
if got.Members[0].OpenDingTalkID != "member-open-id-1" {
t.Fatalf("members[0].open_dingtalk_id = %q, want protocol openDingTalkId value", got.Members[0].OpenDingTalkID)
}
})
}
}
func TestCrossPlatformCoverageGroupMemberBodyUnmarshalErrors(t *testing.T) {
var malformed personalGroupMemberBody
if err := malformed.UnmarshalJSON([]byte(`{`)); err == nil {
t.Fatal("UnmarshalJSON() error = nil, want malformed object error")
}
tests := []struct {
name string
data string
}{
{name: "invalid members", data: `{"members":"invalid"}`},
{name: "invalid operator open id", data: `{"operOpenDingtalkId":{"unexpected":true}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body personalGroupMemberBody
if err := json.Unmarshal([]byte(tt.data), &body); err == nil {
t.Fatal("json.Unmarshal() error = nil, want protocol error")
}
})
}
}
func TestCrossPlatformCoverageProjectOutputRejectsInvalidGroupMemberPayloads(t *testing.T) {
tests := []struct {
name string
data string
}{
{
name: "missing conversation",
data: `{"eventKey":"user_im_group_member_added","payload":{"body":{"members":[{"nick":"测试用户甲","openDingTalkId":"member-1"}]},"event_time":1}}`,
},
{
name: "empty conversation",
data: `{"eventKey":"user_im_group_member_added","payload":{"body":{"openConversationId":" ","members":[{"nick":"测试用户甲","openDingTalkId":"member-1"}]},"event_time":1}}`,
},
{
name: "missing members",
data: `{"eventKey":"user_im_group_member_added","payload":{"body":{"openConversationId":"cid-1"},"event_time":1}}`,
},
{
name: "empty members",
data: `{"eventKey":"user_im_group_member_added","payload":{"body":{"openConversationId":"cid-1","members":[]},"event_time":1}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := transport.Event{EventID: "outer-event", EventType: EventGroupMemberAdded, Data: tt.data}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want group member validation error")
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
func TestCrossPlatformCoverageProjectOutputRejectsInvalidGroupLifecyclePayloads(t *testing.T) {
tests := []struct {
name string
data string
}{
{name: "missing", data: `{"eventKey":"user_im_group_updated"}`},
{name: "null", data: `{"eventKey":"user_im_group_updated","payload":null}`},
{name: "empty object", data: `{"eventKey":"user_im_group_updated","payload":{}}`},
{name: "array", data: `{"eventKey":"user_im_group_updated","payload":[]}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := transport.Event{EventID: "outer-event", EventType: EventGroupUpdated, Data: tt.data}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want payload validation error")
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T) {
tests := []struct {
name string
payload string
}{
{name: "missing"},
{name: "null", payload: `,"payload":null`},
{name: "empty object", payload: `,"payload":{}`},
{name: "array", payload: `,"payload":[]`},
{name: "string", payload: `,"payload":"invalid"`},
{name: "missing body", payload: `,"payload":{"event_time":1}`},
{name: "null body", payload: `,"payload":{"body":null,"event_time":1}`},
{name: "empty body", payload: `,"payload":{"body":{},"event_time":1}`},
}
for _, eventKey := range []string{
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
for _, tt := range tests {
t.Run(eventKey+"/"+tt.name, func(t *testing.T) {
ev := transport.Event{
EventID: "outer-event",
EventType: eventKey,
Data: fmt.Sprintf(`{"eventKey":%q%s}`, eventKey, tt.payload),
}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want OA payload validation error")
}
if !strings.Contains(err.Error(), "decode personal OA payload") {
t.Fatalf("ProjectOutput() error = %v, want OA payload context", err)
}
got, ok := projected.(transport.Event)
if !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
}
func TestCrossPlatformCoverageProjectOutputRejectsOAWithoutStableIDs(t *testing.T) {
tests := []struct {
name string
eventKey string
body string
want string
}{
{
name: "missing process instance",
eventKey: EventOAApprovalInstanceStarted,
body: `{"status":"RUNNING"}`,
want: "processInstanceId is required",
},
{
name: "missing task",
eventKey: EventOAApprovalTaskCreated,
body: `{"processInstanceId":"process-instance-1","status":"RUNNING"}`,
want: "taskId is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := transport.Event{
EventID: "outer-event",
EventType: tt.eventKey,
Data: fmt.Sprintf(
`{"eventKey":%q,"payload":{"body":%s,"event_time":1}}`,
tt.eventKey,
tt.body,
),
}
projected, err := ProjectOutput(ev)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ProjectOutput() error = %v, want %q", err, tt.want)
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
func TestCrossPlatformCoverageProjectOutputDecodesWrappedJSONString(t *testing.T) {
wrapped, err := json.Marshal(personalMessageData(EventSingleChat))
if err != nil {
t.Fatal(err)
}
projected, err := ProjectOutput(transport.Event{Data: string(wrapped)})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(MessageEventOutput)
if got.Content != "在吗" || got.Type != EventSingleChat || got.SubscribeID != "data-sub" || got.EventID != "data-event" {
t.Fatalf("ProjectOutput() = %#v", got)
}
}
func TestCrossPlatformCoverageProjectOutputFallsBackToTransportFields(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventID: "outer-event",
EventBornTime: 123,
EventType: EventSingleChat,
SubscribeID: "outer-sub",
Data: `{"payload":{"body":{"content":"hello"}}}`,
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(MessageEventOutput)
if got.EventID != "outer-event" || got.Timestamp != 123 || got.SubscribeID != "outer-sub" || got.Type != EventSingleChat {
t.Fatalf("fallback fields = %#v", got)
}
}
func TestCrossPlatformCoverageProjectOutputReadEvents(t *testing.T) {
for _, eventKey := range []string{EventReadO2O, EventReadGroup} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventType: eventKey,
Data: personalReadData(eventKey, "read-message", "read-conversation"),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
want := ReadEventOutput{
Type: eventKey,
EventID: "read-event",
Timestamp: 1784008412182,
SubscribeID: "read-sub",
MessageID: "read-message",
ConversationID: "read-conversation",
Reader: "测试用户乙",
ReaderOpenDingTalkID: "reader-open-id",
Sender: "测试用户甲",
SenderOpenDingTalkID: "sender-open-id",
ReadTime: "2026-07-14 13:53:31",
EventTime: 1784008411652,
}
if !reflect.DeepEqual(projected, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputRecallEvents(t *testing.T) {
for _, eventKey := range []string{EventRecallO2O, EventRecallGroup} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventType: eventKey,
Data: personalRecallData(eventKey, "recall-message", "recall-conversation"),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
want := RecallEventOutput{
Type: eventKey,
EventID: "recall-event",
Timestamp: 1784008592969,
SubscribeID: "recall-sub",
MessageID: "recall-message",
ConversationID: "recall-conversation",
Recaller: "测试用户乙",
RecallerOpenDingTalkID: "recaller-open-id",
Sender: "测试用户乙",
SenderOpenDingTalkID: "sender-open-id",
RecallTime: "2026-07-14 13:56:32",
EventTime: 1784008592766,
}
if !reflect.DeepEqual(projected, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputReactionEvents(t *testing.T) {
for _, eventKey := range []string{EventReactionO2O, EventReactionGroup} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventType: eventKey,
Data: personalReactionData(eventKey, "reaction-message", "reaction-conversation"),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
want := ReactionEventOutput{
Type: eventKey,
EventID: "reaction-event",
Timestamp: 1784008680072,
SubscribeID: "reaction-sub",
MessageID: "reaction-message",
ConversationID: "reaction-conversation",
Operator: "测试用户乙",
OperatorOpenDingTalkID: "operator-open-id",
ReactionName: "微笑",
ReactionText: "微笑",
OperationType: "add",
OperationTime: "2026-07-14 13:57:59",
Sender: "测试用户甲",
SenderOpenDingTalkID: "sender-open-id",
EventTime: 1784008679217,
}
if !reflect.DeepEqual(projected, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputReactionRejectsLegacyOperatorOpenIDSpellings(t *testing.T) {
for _, legacyField := range []string{"operOpenDingtlkId", "operOpenDingTalkId"} {
t.Run(legacyField, func(t *testing.T) {
data := strings.Replace(
personalReactionData(EventReactionO2O, "reaction-message", "reaction-conversation"),
"operOpenDingtalkId",
legacyField,
1,
)
projected, err := ProjectOutput(transport.Event{EventType: EventReactionO2O, Data: data})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(ReactionEventOutput)
if got.OperatorOpenDingTalkID != "" {
t.Fatalf("operator_open_dingtalk_id = %q, want empty for legacy field %s", got.OperatorOpenDingTalkID, legacyField)
}
})
}
}
func assertNoInternalActionFields(t *testing.T, projected any) {
t.Helper()
raw, err := json.Marshal(projected)
if err != nil {
t.Fatal(err)
}
for _, field := range []string{"payload", "uid", "corpid", "clientId", "filterSubId", "bizid"} {
if strings.Contains(string(raw), `"`+field+`"`) {
t.Fatalf("projected output leaked internal field %q: %s", field, raw)
}
}
}
func TestCrossPlatformCoverageProjectOutputRejectsEmptyPayloads(t *testing.T) {
eventKeys := []string{
EventMention,
EventSingleChat,
EventInChat,
EventFromUser,
EventAllSingleChat,
EventAllGroupChat,
EventReadO2O,
EventReadGroup,
EventRecallO2O,
EventRecallGroup,
EventReactionO2O,
EventReactionGroup,
EventGroupMemberAdded,
EventGroupMemberExited,
}
payloads := []struct {
name string
json string
}{
{name: "missing", json: ""},
{name: "null", json: `,"payload":null`},
{name: "empty object", json: `,"payload":{}`},
{name: "missing body", json: `,"payload":{"event_time":1}`},
{name: "null body", json: `,"payload":{"body":null}`},
{name: "empty body", json: `,"payload":{"body":{}}`},
}
for _, eventKey := range eventKeys {
for _, payload := range payloads {
t.Run(eventKey+"/"+payload.name, func(t *testing.T) {
ev := transport.Event{
EventID: "outer-event",
EventType: eventKey,
Data: fmt.Sprintf(`{"eventKey":%q%s}`, eventKey, payload.json),
}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want payload validation error")
}
got, ok := projected.(transport.Event)
if !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
}
func TestCrossPlatformCoverageProjectOutputMalformedDataReturnsRawEnvelope(t *testing.T) {
ev := transport.Event{EventID: "outer-event", Data: "not-json"}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want decode error")
}
got, ok := projected.(transport.Event)
if !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v", projected)
}
}
func TestCrossPlatformCoverageSchemaReflectionSupportsNestedArraysAndPointers(t *testing.T) {
type nested struct {
Value string `json:"value" description:"nested value" format:"nested_id"`
Hidden string `json:"-"`
}
type fixture struct {
Items []*nested `json:"items" description:"nested items"`
Meta map[string]any `json:"meta" additional_properties:"true"`
}
schema := schemaForStruct(reflect.TypeOf((*fixture)(nil)))
properties := schema["properties"].(map[string]any)
if len(properties) != 2 {
t.Fatalf("schema properties = %#v, want items and meta", properties)
}
items := properties["items"].(map[string]any)
itemSchema := items["items"].(map[string]any)
itemProperties := itemSchema["properties"].(map[string]any)
if items["type"] != "array" || itemSchema["type"] != "object" || len(itemProperties) != 1 {
t.Fatalf("nested items schema = %#v", items)
}
value := itemProperties["value"].(map[string]any)
if value["type"] != "string" || value["description"] != "nested value" || value["format"] != "nested_id" {
t.Fatalf("nested value schema = %#v", value)
}
meta := properties["meta"].(map[string]any)
if meta["type"] != "object" || meta["additionalProperties"] != true {
t.Fatalf("meta schema = %#v", meta)
}
}