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

1096 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 helpers
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"reflect"
"strings"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type chatMessageSearchCall struct {
productID string
toolName string
args map[string]any
}
type chatMessageSearchCaller struct {
calls []chatMessageSearchCall
searchResponse string
searchResponses []string
searchCalls int
searchError error
failPreflight bool
preflightError error
}
func (c *chatMessageSearchCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args})
text := `{}`
if toolName == "get_conversation_info" {
if c.preflightError != nil {
return nil, c.preflightError
}
if c.failPreflight {
return nil, errors.New("conversation not found")
}
text = `{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`
}
if toolName == "search_messages_by_keyword" || toolName == "search_messages" {
if c.searchError != nil {
c.searchCalls++
return nil, c.searchError
}
text = `{"result":{"messages":[],"hasMore":false}}`
if c.searchCalls < len(c.searchResponses) {
text = c.searchResponses[c.searchCalls]
} else if c.searchResponse != "" {
text = c.searchResponse
}
c.searchCalls++
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (*chatMessageSearchCaller) Format() string { return "json" }
func (*chatMessageSearchCaller) DryRun() bool { return false }
func (*chatMessageSearchCaller) Fields() string { return "" }
func (*chatMessageSearchCaller) JQ() string { return "" }
func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
start := "2026-07-09T00:00:00+08:00"
end := "2026-07-11T00:00:00+08:00"
startTime, err := time.Parse(time.RFC3339, start)
if err != nil {
t.Fatalf("parse start time: %v", err)
}
endTime, err := time.Parse(time.RFC3339, end)
if err != nil {
t.Fatalf("parse end time: %v", err)
}
tests := []struct {
name string
args []string
productID string
toolName string
wantToolArg map[string]any
preflight []string
}{
{
name: "keyword search",
args: []string{"message", "search", "--query", "categoryName", "--group", "cid-1", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"},
productID: "chat",
toolName: "search_messages_by_keyword",
wantToolArg: map[string]any{
"keyword": "categoryName",
"startTime": startTime.UnixMilli(),
"endTime": endTime.UnixMilli(),
"limit": 100,
"cursor": "0",
},
preflight: []string{"cid-1"},
},
{
name: "advanced search",
args: []string{"message", "search-advanced", "--query", "categoryName", "--conversation-ids", "cid-1,cid-2", "--message-type", "text", "--only-robot", "--conversation-type", "group", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"},
productID: "im",
toolName: "search_messages",
wantToolArg: map[string]any{
"keyword": "categoryName",
"messageType": "text",
"onlyRobotMessages": true,
"searchConvType": "group",
"startTime": startTime.UnixMilli(),
"endTime": endTime.UnixMilli(),
"limit": 100,
"cursor": "0",
},
preflight: []string{"cid-1", "cid-2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessageSearchCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
os.Args = []string{"dws", "chat"}
cmd := newChatCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetOut(io.Discard)
cmd.SetArgs(tt.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("chat search returned error: %v", err)
}
if len(caller.calls) != len(tt.preflight)+1 {
t.Fatalf("tool calls = %#v", caller.calls)
}
for index, conversationID := range tt.preflight {
call := caller.calls[index]
if call.productID != "chat" || call.toolName != "get_conversation_info" || call.args["openConversationId"] != conversationID {
t.Fatalf("preflight[%d] = %#v", index, call)
}
}
call := caller.calls[len(caller.calls)-1]
if call.productID != tt.productID || call.toolName != tt.toolName {
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tt.productID, tt.toolName)
}
if !reflect.DeepEqual(call.args, tt.wantToolArg) {
t.Fatalf("tool args = %#v, want %#v", call.args, tt.wantToolArg)
}
})
}
}
func executeNativeScopedSearch(t *testing.T, caller *chatMessageSearchCaller, args ...string) (map[string]any, error) {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
cmd := newChatCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
var output strings.Builder
cmd.SetOut(&output)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal([]byte(output.String()), &payload); err != nil {
return nil, err
}
return payload, nil
}
func TestNativeScopedSearchFiltersGlobalResultsForBothEntries(t *testing.T) {
start := "2026-07-09T00:00:00+08:00"
end := "2026-07-11T00:00:00+08:00"
for _, tt := range []struct {
name string
args []string
tool string
scopeParam string
}{
{
name: "keyword search",
args: []string{"message", "search", "--query", "周报", "--group", "cid-target", "--start", start, "--end", end},
tool: "search_messages_by_keyword",
scopeParam: "openConversationId",
},
{
name: "advanced search",
args: []string{"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target"},
tool: "search_messages",
scopeParam: "openConversationIds",
},
} {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"conversationMessagesList": [
{"openConversationId":"cid-target","title":"目标群","messages":[{"openMessageId":"m-target","content":"目标"}]},
{"openConversationId":"cid-other","title":"其他群","messages":[{"openMessageId":"m-other","content":"越界"}]}
],
"hasMore": false
}
}`}
payload, err := executeNativeScopedSearch(t, caller, tt.args...)
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
groups, _ := result["conversationMessagesList"].([]any)
if len(groups) != 1 {
t.Fatalf("result = %#v", result)
}
group, _ := groups[0].(map[string]any)
if group["openConversationId"] != "cid-target" {
t.Fatalf("group = %#v", group)
}
scope, _ := payload["scope"].(map[string]any)
if scope["targetsValidated"] != true || scope["resultsWithinScope"] != true || scope["filterMode"] != "client" {
t.Fatalf("scope = %#v", scope)
}
searchCall := caller.calls[len(caller.calls)-1]
if searchCall.toolName != tt.tool {
t.Fatalf("search call = %#v", searchCall)
}
if _, exists := searchCall.args[tt.scopeParam]; exists {
t.Fatalf("global fallback unexpectedly forwarded %s: %#v", tt.scopeParam, searchCall.args)
}
})
}
}
func TestNativeScopedSearchInvalidCIDStopsBeforeSearch(t *testing.T) {
caller := &chatMessageSearchCaller{failPreflight: true}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-invalid")
if err == nil {
t.Fatal("invalid CID unexpectedly succeeded")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_invalid" {
t.Fatalf("error = %#v", err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestNativeScopedSearchPreservesPreflightAuthError(t *testing.T) {
want := &CLIError{Code: CodeAuthNotConfigured, Message: "当前未登录"}
caller := &chatMessageSearchCaller{preflightError: want}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err == nil {
t.Fatal("auth failure unexpectedly succeeded")
}
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthNotConfigured {
t.Fatalf("error = %#v", err)
}
}
func TestCrossPlatformCoverageNativeScopedSearchPreservesAmbiguousMCPToolErrors(t *testing.T) {
for _, test := range []struct {
name string
want *CLIError
}{
{
name: "rate limited",
want: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`,
},
},
{
name: "permission denied",
want: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`,
},
},
} {
t.Run(test.name, func(t *testing.T) {
caller := &chatMessageSearchCaller{preflightError: test.want}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err != test.want {
t.Fatalf("error = %#v, want original %#v", err, test.want)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" {
t.Fatalf("calls = %#v", caller.calls)
}
})
}
}
func TestNativeScopedSearchScansUntilTargetConversationAppears(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponses: []string{
`{"result":{"conversationMessagesList":[{"openConversationId":"cid-other","messages":[{"openMessageId":"m-other"}]}],"hasMore":true,"nextCursor":"c2"}}`,
`{"result":{"conversationMessagesList":[{"openConversationId":"cid-target","messages":[{"openMessageId":"m-target"}]}],"hasMore":false}}`,
}}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
if result["pagesFetched"] != float64(2) || result["complete"] != true {
t.Fatalf("result = %#v", result)
}
groups, _ := result["conversationMessagesList"].([]any)
if len(groups) != 1 {
t.Fatalf("groups = %#v", groups)
}
searchCalls := make([]chatMessageSearchCall, 0, 2)
for _, call := range caller.calls {
if call.toolName == "search_messages" {
searchCalls = append(searchCalls, call)
}
}
if len(searchCalls) != 2 || searchCalls[1].args["cursor"] != "c2" {
t.Fatalf("search calls = %#v", searchCalls)
}
}
func TestCrossPlatformCoverageNativeScopedSearchPageAllOptions(t *testing.T) {
t.Run("page limit preserves continuation", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"conversationMessagesList": [
{"openConversationId":"cid-target","messages":[{"openMessageId":"m1"}]}
],
"hasMore": true,
"nextCursor": "c2"
}
}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target",
"--page-all", "--page-limit", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
paging, _ := payload["paging"].(map[string]any)
if paging["pages"] != float64(1) || paging["total"] != float64(1) || paging["truncated"] != true {
t.Fatalf("paging = %#v", paging)
}
if caller.searchCalls != 1 {
t.Fatalf("search calls = %d, want 1", caller.searchCalls)
}
})
t.Run("max items truncates within filtered page", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"conversationMessagesList": [
{"openConversationId":"cid-target","messages":[
{"openMessageId":"m1"},
{"openMessageId":"m2"}
]}
],
"hasMore": false
}
}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target",
"--page-all", "--max-items", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
groups, _ := result["conversationMessagesList"].([]any)
group, _ := groups[0].(map[string]any)
messages, _ := group["messages"].([]any)
if len(messages) != 1 {
t.Fatalf("messages = %#v", messages)
}
paging, _ := payload["paging"].(map[string]any)
if paging["total"] != float64(1) || paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false {
t.Fatalf("paging = %#v", paging)
}
})
}
func TestNativeScopedSearchMissingConversationIdentityFailsClosed(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":false}}`}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err == nil {
t.Fatal("unverifiable scoped result unexpectedly succeeded")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_unverified" {
t.Fatalf("error = %#v", err)
}
}
func TestNativeScopedSearchValidEmptyResultIsComplete(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-empty")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
if result["complete"] != true || result["hasMore"] != false {
t.Fatalf("result = %#v", result)
}
scope, _ := payload["scope"].(map[string]any)
if scope["targetsValidated"] != true || scope["sourceComplete"] != true {
t.Fatalf("scope = %#v", scope)
}
}
func TestCrossPlatformCoverageNativeScopedSearchFailureAndPaginationBranches(t *testing.T) {
t.Run("empty scope uses the native search call", func(t *testing.T) {
caller := &chatMessageSearchCaller{}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
cmd := &cobra.Command{Use: "search"}
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{"keyword": "周报"},
[]string{"", " "},
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "search_messages" {
t.Fatalf("calls = %#v", caller.calls)
}
})
t.Run("invalid page options fail before preflight", func(t *testing.T) {
caller := &chatMessageSearchCaller{}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
cmd := &cobra.Command{Use: "search"}
AddPagedMCPFlags(cmd)
if err := cmd.Flags().Set("page-all", "true"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("page-limit", "0"); err != nil {
t.Fatal(err)
}
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{"keyword": "周报"},
[]string{"cid-target"},
)
if err == nil || !strings.Contains(err.Error(), "--page-limit must be between 1 and 500") {
t.Fatalf("error = %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid paging made calls: %#v", caller.calls)
}
})
t.Run("cancelled context interrupts page delay", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
"hasMore": true,
"nextCursor": "c2"
}
}`}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
cmd := &cobra.Command{Use: "search"}
AddPagedMCPFlags(cmd)
if err := cmd.Flags().Set("page-all", "true"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("page-delay", "60000"); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd.SetContext(ctx)
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{"keyword": "周报", "limit": 100, "cursor": "0"},
[]string{"cid-target"},
)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context canceled", err)
}
if caller.searchCalls != 1 {
t.Fatalf("search calls = %d, want 1", caller.searchCalls)
}
})
t.Run("lower search error", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchError: errors.New("search unavailable")}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err == nil {
t.Fatal("lower search error was ignored")
}
})
t.Run("stalled cursor fails closed", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
"hasMore": true
}
}`}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_cursor_stalled" {
t.Fatalf("error = %#v", err)
}
})
t.Run("result limit preserves continuation", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
"hasMore": true,
"nextCursor": "c2"
}
}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target", "--limit", "1")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
if result["complete"] != false || result["hasMore"] != true || result["nextCursor"] != "c2" {
t.Fatalf("result = %#v", result)
}
})
t.Run("duplicate message ids are removed across pages", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponses: []string{
`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"}],"hasMore":true,"nextCursor":"c2"}}`,
`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"},{"openMessageId":"m2","openConversationId":"cid-target"}],"hasMore":false}}`,
}}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
groups, _ := result["conversationMessagesList"].([]any)
group, _ := groups[0].(map[string]any)
messages, _ := group["messages"].([]any)
if len(messages) != 2 {
t.Fatalf("deduplicated messages = %#v", messages)
}
})
if got := uniqueNonEmptyStrings([]string{" cid ", "", "cid"}); !reflect.DeepEqual(got, []string{"cid"}) {
t.Fatalf("uniqueNonEmptyStrings = %#v", got)
}
for _, test := range []struct {
value any
want int
}{
{value: int64(7), want: 7},
{value: json.Number("8"), want: 8},
{value: float64(9), want: 9},
{value: int64(0), want: 11},
} {
if got := positiveSearchLimit(test.value, 11); got != test.want {
t.Errorf("positiveSearchLimit(%#v) = %d, want %d", test.value, got, test.want)
}
}
if cleanSearchCursor(nil) != "" || cleanSearchCursor(" null ") != "" || cleanSearchCursor(" c2 ") != "c2" {
t.Fatal("cleanSearchCursor did not normalize sentinel values")
}
}
func TestCrossPlatformCoverageNativeScopedSearchDryRunShowsCompositePlanWithoutCallingTools(t *testing.T) {
caller := &chatMessageSearchCaller{}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
cmd := &cobra.Command{Use: "search"}
cmd.Flags().Bool("dry-run", true, "")
AddPagedMCPFlags(cmd)
for name, value := range map[string]string{
"page-all": "true",
"page-limit": "7",
"max-items": "9",
"page-delay": "11",
} {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
var output strings.Builder
cmd.SetOut(&output)
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{
"keyword": "周报",
"openConversationIds": []string{"cid-target"},
"limit": 100,
"cursor": "0",
},
[]string{"cid-target"},
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run made calls: %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal([]byte(output.String()), &payload); err != nil {
t.Fatal(err)
}
plan, _ := payload["plan"].([]any)
if payload["dry_run"] != true || payload["executed"] != false || len(plan) != 3 {
t.Fatalf("payload = %#v", payload)
}
searchStage, _ := plan[1].(map[string]any)
arguments, _ := searchStage["arguments"].(map[string]any)
if _, exists := arguments["openConversationIds"]; exists {
t.Fatalf("dry-run global search still carries scope: %#v", searchStage)
}
if searchStage["pageAll"] != true ||
searchStage["pageLimit"] != float64(7) ||
searchStage["maxItems"] != float64(9) ||
searchStage["pageDelay"] != float64(11) {
t.Fatalf("dry-run paging = %#v", searchStage)
}
}
type chatChangedContractCaller struct {
calls []chatMessageSearchCall
resolveUsers bool
}
func (c *chatChangedContractCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args})
text := `{}`
if c.resolveUsers && toolName == "get_user_info_by_user_ids" {
text = `{"result":[{"userId":"123","openDingTalkId":"open-123"}]}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (*chatChangedContractCaller) Format() string { return "json" }
func (*chatChangedContractCaller) DryRun() bool { return false }
func (*chatChangedContractCaller) Fields() string { return "" }
func (*chatChangedContractCaller) JQ() string { return "" }
func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller, args ...string) error {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
cmd := newChatCommand()
installExampleGlobalFlags(cmd)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(append(append([]string(nil), args...), "--yes"))
return cmd.Execute()
}
func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller,
"message", "list", "--group", "cid-1", "--time", "2026-07-15 09:00:00", "--limit", "50")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "list_conversation_message_v2" {
t.Fatalf("calls = %#v", caller.calls)
}
want := map[string]any{"openconversation_id": "cid-1", "time": "2026-07-15 09:00:00", "forward": true, "limit": 50}
if !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want)
}
}
func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t *testing.T) {
previousLocal := time.Local
t.Cleanup(func() { time.Local = previousLocal })
tests := []struct {
name string
args []string
wantTool string
wantTarget map[string]any
}{
{
name: "group",
args: []string{"message", "list", "--group", "cid-1", "--limit", "50"},
wantTool: "list_conversation_message_v2",
wantTarget: map[string]any{"openconversation_id": "cid-1"},
},
{
name: "user",
args: []string{"message", "list", "--user", "user-1", "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"userId": "user-1"},
},
{
name: "user open DingTalk ID fallback",
args: []string{"message", "list", "--user", helperCurrentDOpenID, "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
},
{
name: "open DingTalk ID",
args: []string{"message", "list", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
},
{
name: "direct user",
args: []string{"message", "list-direct", "--user", "user-1", "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"userId": "user-1"},
},
{
name: "direct open DingTalk ID",
args: []string{"message", "list-direct", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"},
wantTool: "list_individual_chat_message",
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
},
}
for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} {
t.Run(loc.String(), func(t *testing.T) {
time.Local = loc
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
before := time.Now()
err := executeChatChangedContract(t, caller, tt.args...)
after := time.Now()
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one MCP call", caller.calls)
}
if caller.calls[0].toolName != tt.wantTool {
t.Fatalf("tool = %q, want %q", caller.calls[0].toolName, tt.wantTool)
}
for key, want := range tt.wantTarget {
if got := caller.calls[0].args[key]; got != want {
t.Fatalf("arg %s = %#v, want %#v", key, got, want)
}
}
raw, ok := caller.calls[0].args["time"].(string)
if !ok || raw == "" {
t.Fatalf("time arg = %#v, want non-empty string", caller.calls[0].args["time"])
}
gotMs, err := parseISOTimeToMillis("time", raw)
if err != nil {
t.Fatalf("time arg = %q, parse err = %v", raw, err)
}
wantMin := before.Add(-time.Second).UnixMilli()
wantMax := after.Add(time.Second).UnixMilli()
if gotMs < wantMin || gotMs > wantMax {
t.Fatalf("time arg = %q (%d), want between %d and %d", raw, gotMs, wantMin, wantMax)
}
if caller.calls[0].args["forward"] != false {
t.Fatalf("forward = %#v, want false when --time is omitted", caller.calls[0].args["forward"])
}
})
}
})
}
}
func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller,
"group", "audit-join-validation",
"--group", "cid-1", "--record-id", "123", "--applicant", "user-a", "--inviter", "user-b", "--status", "AuditApprove")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "audit_join_group" {
t.Fatalf("calls = %#v", caller.calls)
}
want := map[string]any{
"openConversationId": "cid-1", "applyRecordId": int64(123),
"applicantUid": "user-a", "inviterUid": "user-b", "status": "AuditApprove",
}
if !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want)
}
}
func TestCrossPlatformCoverageChatAuditRejectsUnsupportedStatus(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller,
"group", "audit-join-validation",
"--group", "cid-1", "--record-id", "123", "--applicant", "user-a", "--inviter", "user-b", "--status", "AuditRefuse")
if err == nil {
t.Fatal("expected unsupported audit status error")
}
if !strings.Contains(err.Error(), `unsupported audit status "AuditRefuse"`) {
t.Fatalf("error = %v, want unsupported status", err)
}
if len(caller.calls) != 0 {
t.Fatalf("unsupported status must not call MCP: %#v", caller.calls)
}
}
func TestCrossPlatformCoverageChatSendResolvesUserBeforeDispatch(t *testing.T) {
caller := &chatChangedContractCaller{resolveUsers: true}
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 || caller.calls[1].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
if got := caller.calls[1].args["receiverOpenDingTalkId"]; got != "open-123" {
t.Fatalf("receiverOpenDingTalkId = %#v, args = %#v", got, caller.calls[1].args)
}
if _, leaked := caller.calls[1].args["receiverUid"]; leaked {
t.Fatalf("resolved send must not include receiverUid: %#v", caller.calls[1].args)
}
}
func TestChatSendAndReplyDefaultToAgentProductForIMClawType(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
tests := []struct {
name string
args []string
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
if got := caller.calls[0].args["clawType"]; got != "qwenwork" {
t.Fatalf("clawType = %#v, want qwenwork", got)
}
})
}
}
func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
tests := []struct {
name string
args []string
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
got, present := caller.calls[0].args["clawType"]
if !present || got != "" {
t.Fatalf("clawType = %#v, present = %v; want present empty string", got, present)
}
})
}
}
func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) {
tests := []struct {
name string
args []string
contentField string
wantContent string
wantAtAll bool
wantOpenIDs []string
}{
{
name: "send",
args: []string{
"message", "send", "--group", "cid",
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
"--at-all",
},
contentField: "text",
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
wantAtAll: true,
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
},
{
name: "send keeps missing member placeholders unchanged",
args: []string{
"message", "send", "--group", "cid",
"--text", "DWS 发消息自测",
"--at-open-dingtalk-ids", helperCurrentDOpenID,
},
contentField: "text",
wantContent: "DWS 发消息自测",
wantOpenIDs: []string{helperCurrentDOpenID},
},
{
name: "reply",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", helperCurrentDOpenID,
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
"--at-all",
},
contentField: "content",
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
wantAtAll: true,
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
},
{
name: "reply adds missing member placeholders",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", helperCurrentDOpenID,
"--text", "DWS synthetic reply mention test",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2 + "," + helperCurrentDOpenID,
},
contentField: "content",
wantContent: "<@" + helperCurrentDOpenID + "> <@" + helperCurrentDOpenID2 + "> DWS synthetic reply mention test",
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2, helperCurrentDOpenID},
},
{
name: "reply adds missing member placeholders after at-all",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", helperCurrentDOpenID,
"--text", "请大家确认",
"--at-open-dingtalk-ids", helperCurrentDOpenID,
"--at-all",
},
contentField: "content",
wantContent: "<@all> <@" + helperCurrentDOpenID + "> 请大家确认",
wantAtAll: true,
wantOpenIDs: []string{helperCurrentDOpenID},
},
{
name: "reply at-all preserves alliance word",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", helperCurrentDOpenID,
"--text", "联系 @alliance",
"--at-all",
},
contentField: "content",
wantContent: "<@all> 联系 @alliance",
wantAtAll: true,
},
{
name: "reply without at flags preserves alliance word",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", helperCurrentDOpenID,
"--text", "联系 @alliance",
},
contentField: "content",
wantContent: "联系 @alliance",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
args := caller.calls[0].args
gotAtAll, hasAtAll := args["atAll"]
if tc.wantAtAll {
if !hasAtAll || gotAtAll != true {
t.Fatalf("atAll = %#v, present = %v; want true", gotAtAll, hasAtAll)
}
} else if hasAtAll {
t.Fatalf("atAll = %#v; want absent", gotAtAll)
}
gotOpenIDs, hasOpenIDs := args["atOpenDingTalkIds"]
if len(tc.wantOpenIDs) > 0 {
if !hasOpenIDs || !reflect.DeepEqual(gotOpenIDs, tc.wantOpenIDs) {
t.Fatalf("atOpenDingTalkIds = %#v, present = %v; want %#v", gotOpenIDs, hasOpenIDs, tc.wantOpenIDs)
}
} else if hasOpenIDs {
t.Fatalf("atOpenDingTalkIds = %#v; want absent", gotOpenIDs)
}
var content map[string]string
if err := json.Unmarshal([]byte(args["content"].(string)), &content); err != nil {
t.Fatal(err)
}
if got := content[tc.contentField]; got != tc.wantContent {
t.Fatalf("content[%q] = %q; want %q", tc.contentField, got, tc.wantContent)
}
})
}
}
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
if err == nil || !strings.Contains(err.Error(), "pass --open-dingtalk-id instead") {
t.Fatalf("error = %v, want explicit resolution failure", err)
}
for _, call := range caller.calls {
if call.toolName == "send_personal_message" {
t.Fatalf("unresolved user must not be dispatched: %#v", caller.calls)
}
}
}