1131 lines
36 KiB
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)
|
|
}
|
|
}
|