Files
dingtalk-workspace-cli/internal/helpers/im_read_result_contract_test.go

561 lines
21 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 helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type imReadResultCall struct {
productID string
toolName string
}
type imReadResultCaller struct {
responses map[string]string
results map[string]*edition.ToolResult
errors map[string]error
calls []imReadResultCall
args map[string]any
dryRun bool
}
func (c *imReadResultCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, imReadResultCall{productID: productID, toolName: toolName})
c.args = map[string]any{}
for k, v := range args {
c.args[k] = v
}
if err := c.errors[toolName]; err != nil {
return nil, err
}
if result, ok := c.results[toolName]; ok {
return result, nil
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.responses[toolName]}}}, nil
}
func TestCrossPlatformCoverageChatMessageReadsPreserveToolOperationOnCallerError(t *testing.T) {
caller := &imReadResultCaller{errors: map[string]error{
"list_conversation_message_v2": errors.New("dial tcp: connection refused"),
}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
if err == nil {
t.Fatalf("chat message list unexpectedly succeeded with output %q", got)
}
if got != "" {
t.Fatalf("chat message list output = %q, want no success payload", got)
}
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Operation != "chat/list_conversation_message_v2" {
t.Fatalf("chat message list error = %#v", err)
}
existing := &CLIError{
Code: CodeNetworkTimeout,
Message: "request already classified",
Operation: "chat/existing-operation",
}
caller = &imReadResultCaller{errors: map[string]error{
"list_conversation_message_v2": existing,
}}
got, err = executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
if err != existing {
t.Fatalf("chat message list error = %#v, want original %#v", err, existing)
}
if got != "" {
t.Fatalf("chat message list output = %q, want no success payload", got)
}
}
func (*imReadResultCaller) Format() string { return "json" }
func (c *imReadResultCaller) DryRun() bool { return c.dryRun }
func (*imReadResultCaller) Fields() string { return "" }
func (*imReadResultCaller) JQ() string { return "" }
func executeIMReadCommand(t *testing.T, caller *imReadResultCaller, processArgs []string, build func() *cobra.Command, args ...string) (string, error) {
t.Helper()
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
InitDeps(caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
deps.Out.errW = io.Discard
os.Args = processArgs
root := build()
installExampleGlobalFlags(root)
root.SetOut(&stdout)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
err := root.Execute()
return stdout.String(), err
}
func requireSameJSON(t *testing.T, got, want string) {
t.Helper()
var gotValue any
if err := json.Unmarshal([]byte(got), &gotValue); err != nil {
t.Fatalf("decode command output: %v\noutput: %s", err, got)
}
var wantValue any
if err := json.Unmarshal([]byte(want), &wantValue); err != nil {
t.Fatalf("decode fixture: %v", err)
}
if !reflect.DeepEqual(gotValue, wantValue) {
t.Fatalf("command output = %#v, want %#v", gotValue, wantValue)
}
}
func TestCrossPlatformCoverageChatMessageListProjectsStableFieldsAndPreservesLegacy(t *testing.T) {
payload := `{
"result": {
"messages": [
{"openMessageId":"msg-1","content":{"text":"回复正文"},"createTime":101,"msgType":"reply","quotedMessage":{"msgType":"merged_forward","content":{"items":[{"text":"原消息"}]}}},
{"openMessageId":"msg-2","content":{"text":"图片回复"},"createTime":102,"msgType":"reply","quotedMessage":{"msgType":"image","content":{"mediaId":"media-1"}}}
],
"hasMore": false
}
}`
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00", "--limit", "50")
if err != nil {
t.Fatalf("chat message list returned error: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "list_conversation_message_v2"}) {
t.Fatalf("calls = %#v, want chat/list_conversation_message_v2", caller.calls)
}
var result map[string]any
if err := json.Unmarshal([]byte(got), &result); err != nil {
t.Fatalf("decode command output: %v\noutput: %s", err, got)
}
if _, exists := result["contractVersion"]; exists {
t.Fatalf("typed list added an out-of-scope contract envelope: %#v", result)
}
messages, ok := result["messages"].([]any)
if !ok || len(messages) != 2 {
t.Fatalf("messages = %#v", result["messages"])
}
first, ok := messages[0].(map[string]any)
if !ok || first["messageId"] != "msg-1" || first["openMessageId"] != "msg-1" || first["text"] != "回复正文" {
t.Fatalf("first projected message = %#v", messages[0])
}
if _, ok := first["content"].(map[string]any); !ok {
t.Fatalf("legacy content not preserved: %#v", first)
}
quoted, ok := first["quotedMessage"].(map[string]any)
if !ok || quoted["msgType"] != "merged_forward" {
t.Fatalf("merged-forward quote = %#v", first["quotedMessage"])
}
quotedContent, ok := quoted["content"].(map[string]any)
items, itemsOK := quotedContent["items"].([]any)
if !ok || !itemsOK || len(items) != 1 {
t.Fatalf("merged-forward quote content = %#v", quoted["content"])
}
second, ok := messages[1].(map[string]any)
if !ok {
t.Fatalf("second projected message = %#v", messages[1])
}
quoted, ok = second["quotedMessage"].(map[string]any)
if !ok || quoted["msgType"] != "image" {
t.Fatalf("image quote = %#v", second["quotedMessage"])
}
quotedContent, ok = quoted["content"].(map[string]any)
if !ok || quotedContent["mediaId"] != "media-1" {
t.Fatalf("image quote content = %#v", quoted["content"])
}
legacy, ok := result["result"].(map[string]any)
if !ok {
t.Fatalf("legacy result envelope missing: %#v", result["result"])
}
legacyMessages, ok := legacy["messages"].([]any)
if !ok || len(legacyMessages) != 2 {
t.Fatalf("legacy result.messages = %#v", legacy["messages"])
}
}
func TestCrossPlatformCoverageChatMessageListDefaultsTimeAndOlderDirection(t *testing.T) {
payload := `{"result":{"messages":[],"hasMore":false}}`
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
loc := shanghaiLocation()
before := time.Now().In(loc)
_, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--limit", "50")
after := time.Now().In(loc)
if err != nil {
t.Fatalf("chat message list returned error: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "list_conversation_message_v2"}) {
t.Fatalf("calls = %#v, want chat/list_conversation_message_v2", caller.calls)
}
if got := caller.args["forward"]; got != false {
t.Fatalf("forward = %#v, want false when --time is omitted", got)
}
timeRaw, ok := caller.args["time"].(string)
if !ok || strings.TrimSpace(timeRaw) == "" {
t.Fatalf("time arg = %#v, want generated current time", caller.args["time"])
}
gotTime, err := time.ParseInLocation("2006-01-02 15:04:05", timeRaw, loc)
if err != nil {
t.Fatalf("parse generated time %q: %v", timeRaw, err)
}
if gotTime.Before(before.Add(-time.Second)) || gotTime.After(after.Add(time.Second)) {
t.Fatalf("time = %s, want between %s and %s", gotTime, before, after)
}
}
func TestCrossPlatformCoverageChatMessageSearchProjectsStableFieldsAndPreservesLegacy(t *testing.T) {
payload := `{
"result": {
"conversationMessagesList": [{
"openConversationId": "cid-1",
"title": "项目群",
"messages": [{"openMessageId":"msg-search-1","messageId":"legacy-conflict","content":{"richText":"发布计划"},"createTime":201}]
}],
"nextCursor": "cursor-2"
}
}`
caller := &imReadResultCaller{responses: map[string]string{"search_messages_by_keyword": payload}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "search", "--query", "发布计划",
"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-10T00:00:00+08:00")
if err != nil {
t.Fatalf("chat message search returned error: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "chat", toolName: "search_messages_by_keyword"}) {
t.Fatalf("calls = %#v, want chat/search_messages_by_keyword", caller.calls)
}
var result map[string]any
if err := json.Unmarshal([]byte(got), &result); err != nil {
t.Fatalf("decode command output: %v\noutput: %s", err, got)
}
if _, exists := result["contractVersion"]; exists {
t.Fatalf("typed search added an out-of-scope contract envelope: %#v", result)
}
messages, ok := result["messages"].([]any)
if !ok || len(messages) != 1 {
t.Fatalf("messages = %#v", result["messages"])
}
message, ok := messages[0].(map[string]any)
if !ok || message["messageId"] != "msg-search-1" || message["openMessageId"] != "msg-search-1" ||
message["conversationId"] != "cid-1" || message["text"] != "发布计划" {
t.Fatalf("projected search message = %#v", messages[0])
}
if _, ok := message["content"].(map[string]any); !ok {
t.Fatalf("legacy content not preserved: %#v", message)
}
legacy, ok := result["result"].(map[string]any)
if !ok {
t.Fatalf("legacy result envelope missing: %#v", result["result"])
}
legacyGroups, ok := legacy["conversationMessagesList"].([]any)
if !ok || len(legacyGroups) != 1 {
t.Fatalf("legacy result.conversationMessagesList = %#v", legacy["conversationMessagesList"])
}
if legacy["nextCursor"] != "cursor-2" {
t.Fatalf("legacy result.nextCursor = %#v", legacy["nextCursor"])
}
}
func TestCrossPlatformCoverageChatMessageScopedSearchProjectsStableFields(t *testing.T) {
payload := `{
"result": {
"conversationMessagesList": [{
"openConversationId": "cid-1",
"messages": [{"openMessageId":"msg-scoped-1","content":{"text":"范围内消息"}}]
}],
"hasMore": false
}
}`
caller := &imReadResultCaller{responses: map[string]string{
"get_conversation_info": `{}`,
"search_messages_by_keyword": payload,
}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "search", "--query", "范围内消息", "--group", "cid-1",
"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-10T00:00:00+08:00")
if err != nil {
t.Fatalf("scoped chat message search returned error: %v", err)
}
wantCalls := []imReadResultCall{
{productID: "chat", toolName: "get_conversation_info"},
{productID: "chat", toolName: "search_messages_by_keyword"},
}
if !reflect.DeepEqual(caller.calls, wantCalls) {
t.Fatalf("calls = %#v, want %#v", caller.calls, wantCalls)
}
var result map[string]any
if err := json.Unmarshal([]byte(got), &result); err != nil {
t.Fatalf("decode command output: %v\noutput: %s", err, got)
}
messages, ok := result["messages"].([]any)
if !ok || len(messages) != 1 {
t.Fatalf("messages = %#v", result["messages"])
}
message, _ := messages[0].(map[string]any)
if message["messageId"] != "msg-scoped-1" || message["openMessageId"] != "msg-scoped-1" ||
message["conversationId"] != "cid-1" || message["text"] != "范围内消息" {
t.Fatalf("projected scoped message = %#v", message)
}
scope, _ := result["scope"].(map[string]any)
if scope["targetsValidated"] != true || scope["resultsWithinScope"] != true || scope["sourceComplete"] != true {
t.Fatalf("scope = %#v", scope)
}
legacy, _ := result["result"].(map[string]any)
if _, ok := legacy["conversationMessagesList"].([]any); !ok {
t.Fatalf("legacy result envelope missing: %#v", legacy)
}
}
func TestCrossPlatformCoverageChatMessageListPreservesNonJSONResponse(t *testing.T) {
const payload = "upstream temporarily returned plain text"
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
if err != nil {
t.Fatalf("chat message list returned error: %v", err)
}
if got != payload+"\n" {
t.Fatalf("command output = %q, want raw payload", got)
}
}
func TestCrossPlatformCoverageChatMessageReadsRejectEmptyToolResults(t *testing.T) {
tests := []struct {
name string
toolName string
caller *imReadResultCaller
args []string
}{
{
name: "list empty text",
toolName: "list_conversation_message_v2",
caller: &imReadResultCaller{responses: map[string]string{
"list_conversation_message_v2": " \n ",
}},
args: []string{"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00"},
},
{
name: "search without text content",
toolName: "search_messages_by_keyword",
caller: &imReadResultCaller{results: map[string]*edition.ToolResult{
"search_messages_by_keyword": {Content: []edition.ContentBlock{{Type: "image"}}},
}},
args: []string{"message", "search", "--query", "发布计划",
"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-10T00:00:00+08:00"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := executeIMReadCommand(t, tc.caller, []string{"dws", "chat"}, newChatCommand, tc.args...)
if err == nil {
t.Fatalf("%s unexpectedly succeeded with output %q", tc.toolName, got)
}
if got != "" {
t.Fatalf("%s output = %q, want no success payload", tc.toolName, got)
}
var apiErr *apperrors.Error
if !errors.As(err, &apiErr) || apiErr.Reason != "empty_tool_response" ||
apiErr.Operation != "chat/"+tc.toolName || !apiErr.RetryableSet || !apiErr.Retryable {
t.Fatalf("%s error = %#v", tc.toolName, err)
}
})
}
}
func TestCrossPlatformCoverageChatMessageListPreservesTopLevelMessageFields(t *testing.T) {
payload := `{
"messages": [{
"openMessageId": "msg-top-1",
"content": {"text": "顶层正文"},
"msgType": "text",
"sender": {"name": "张三", "department": "研发"},
"senderName": "张三",
"quotedMessage": {
"msgType": "image",
"content": {"mediaId": "media-1", "caption": "原图说明"},
"extension": {"source": "legacy-quote"}
},
"forwarded": [{
"openMessageId": "forward-1",
"content": {"text": "转发正文"},
"extension": {"source": "legacy-forward"}
}],
"reactions": [{
"emoji": "like",
"count": 2,
"extension": {"source": "legacy-reaction"}
}],
"extensionField": {"source": "legacy"}
}, {
"messageId": "msg-top-2",
"text": "稳定正文"
}],
"count": 99,
"partial": true,
"failedCount": 1,
"failures": [{"stage":"legacy","error":"legacy failure"}],
"hasMore": false
}`
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
if err != nil {
t.Fatalf("chat message list returned error: %v", err)
}
var result map[string]any
if err := json.Unmarshal([]byte(got), &result); err != nil {
t.Fatalf("decode command output: %v\noutput: %s", err, got)
}
messages, ok := result["messages"].([]any)
if !ok || len(messages) != 2 {
t.Fatalf("messages = %#v", result["messages"])
}
message, ok := messages[0].(map[string]any)
if !ok {
t.Fatalf("message = %#v", messages[0])
}
if message["messageId"] != "msg-top-1" || message["text"] != "顶层正文" {
t.Fatalf("stable fields = %#v", message)
}
if message["msgType"] != "text" || message["senderName"] != "张三" {
t.Fatalf("legacy message fields = %#v", message)
}
if !reflect.DeepEqual(message["sender"], map[string]any{"name": "张三", "department": "研发"}) {
t.Fatalf("legacy sender = %#v", message["sender"])
}
wantQuoted := map[string]any{
"msgType": "image",
"content": map[string]any{"mediaId": "media-1", "caption": "原图说明"},
"extension": map[string]any{"source": "legacy-quote"},
}
if !reflect.DeepEqual(message["quotedMessage"], wantQuoted) {
t.Fatalf("legacy quotedMessage = %#v", message["quotedMessage"])
}
wantForwarded := []any{map[string]any{
"openMessageId": "forward-1",
"content": map[string]any{"text": "转发正文"},
"extension": map[string]any{"source": "legacy-forward"},
}}
if !reflect.DeepEqual(message["forwarded"], wantForwarded) {
t.Fatalf("legacy forwarded = %#v", message["forwarded"])
}
wantReactions := []any{map[string]any{
"emoji": "like",
"count": float64(2),
"extension": map[string]any{"source": "legacy-reaction"},
}}
if !reflect.DeepEqual(message["reactions"], wantReactions) {
t.Fatalf("legacy reactions = %#v", message["reactions"])
}
if extension, ok := message["extensionField"].(map[string]any); !ok || extension["source"] != "legacy" {
t.Fatalf("extensionField = %#v", message["extensionField"])
}
stableOnly, ok := messages[1].(map[string]any)
if !ok || stableOnly["openMessageId"] != "msg-top-2" || stableOnly["content"] != "稳定正文" {
t.Fatalf("legacy aliases from stable fields = %#v", messages[1])
}
if result["count"] != float64(99) || result["partial"] != true || result["failedCount"] != float64(1) {
t.Fatalf("legacy top-level envelope fields = %#v", result)
}
if _, exists := result["contractVersion"]; exists {
t.Fatalf("typed list added an out-of-scope contract envelope: %#v", result)
}
failures, ok := result["failures"].([]any)
if !ok || len(failures) != 1 || failures[0].(map[string]any)["stage"] != "legacy" {
t.Fatalf("legacy failures = %#v", result["failures"])
}
}
func TestCrossPlatformCoverageChatMessageListDryRunKeepsPreviewPath(t *testing.T) {
caller := &imReadResultCaller{dryRun: true}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00")
if err != nil {
t.Fatalf("chat message list dry-run returned error: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run calls = %#v, want none", caller.calls)
}
var preview map[string]any
if err := json.Unmarshal([]byte(got), &preview); err != nil {
t.Fatalf("decode dry-run output: %v\noutput: %s", err, got)
}
if preview["dry_run"] != true || preview["executed"] != false || preview["tool"] != "list_conversation_message_v2" {
t.Fatalf("dry-run preview = %#v", preview)
}
}
func TestCrossPlatformCoverageChatMessageListRawPreservesLargeIntegers(t *testing.T) {
const payload = `{"messages":[{"openMessageId":"msg-1","content":{"text":"正文"},"sequence":9007199254740993}],"hasMore":false}`
caller := &imReadResultCaller{responses: map[string]string{"list_conversation_message_v2": payload}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "chat"}, newChatCommand,
"message", "list", "--group", "cid-1", "--time", "2026-07-14 00:00:00", "--format", "raw")
if err != nil {
t.Fatalf("chat message list returned error: %v", err)
}
if !bytes.Contains([]byte(got), []byte(`"sequence":9007199254740993`)) {
t.Fatalf("raw output changed large integer: %s", got)
}
}
func TestDingMessageListPreservesContent(t *testing.T) {
payload := `{"result":{"dingMessages":[{"openDingId":"ding-1","status":"READ","content":"升级提醒"}]}}`
caller := &imReadResultCaller{responses: map[string]string{"list_ding_messages": payload}}
got, err := executeIMReadCommand(t, caller, []string{"dws", "ding"}, newDingCommand,
"message", "list", "--type", "ALL")
if err != nil {
t.Fatalf("ding message list returned error: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0] != (imReadResultCall{productID: "im", toolName: "list_ding_messages"}) {
t.Fatalf("calls = %#v, want im/list_ding_messages", caller.calls)
}
requireSameJSON(t, got, payload)
}