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

387 lines
12 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 TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
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)
}
})
}
}
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,
EventReadO2O,
EventReadGroup,
EventRecallO2O,
EventRecallGroup,
EventReactionO2O,
EventReactionGroup,
}
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)
}
}