Files
dingtalk-workspace-cli/internal/shortcut/smart/search_msg_execution_test.go
T

468 lines
17 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.
package smart
import (
"bytes"
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type searchMsgExecutionCaller struct {
calls []platformCoverageCall
failSecondPage bool
failEnrichment bool
omitPagination bool
omitMgetItem bool
failPreflight bool
preflightError error
searchResponse string
wrongMgetScope bool
missingMgetCID bool
firstResponse string
mgetResponse string
numericZeroEnd bool
}
func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args})
if product == "chat" && tool == "get_conversation_info" {
if f.preflightError != nil {
return nil, f.preflightError
}
if f.failPreflight {
return nil, errors.New("conversation not found")
}
return searchMsgToolResult(`{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`), nil
}
if product != "im" {
return nil, errors.New("unexpected product")
}
switch tool {
case "search_messages":
if f.searchResponse != "" {
return searchMsgToolResult(f.searchResponse), nil
}
if f.omitPagination {
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}]}}`), nil
}
if f.numericZeroEnd {
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}],"hasMore":false,"nextCursor":0}}`), nil
}
if args["cursor"] == "c2" {
if f.failSecondPage {
return nil, errors.New("second page unavailable")
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","content":"sparse-2"}],"hasMore":false}}`), nil
}
if f.firstResponse != "" {
return searchMsgToolResult(f.firstResponse), nil
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
case "list_messages_by_ids":
if f.failEnrichment {
return nil, errors.New("mget unavailable")
}
if f.wrongMgetScope {
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","openConversationId":"cid-other","content":"detail-1"}]}`), nil
}
if f.missingMgetCID {
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","openConversationId":null,"content":"detail-1"}]}`), nil
}
if f.omitMgetItem {
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"}]}`), nil
}
if f.mgetResponse != "" {
return searchMsgToolResult(f.mgetResponse), nil
}
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"},{"openMessageId":"m2","content":"detail-2"}]}`), nil
default:
return nil, errors.New("unexpected tool")
}
}
func (f *searchMsgExecutionCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
return f.CallTool(ctx, product, tool, args)
}
func (*searchMsgExecutionCaller) Format() string { return "json" }
func (*searchMsgExecutionCaller) DryRun() bool { return false }
func (*searchMsgExecutionCaller) Fields() string { return "" }
func (*searchMsgExecutionCaller) JQ() string { return "" }
func searchMsgToolResult(text string) *edition.ToolResult {
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
}
func executeSearchMsg(t *testing.T, caller *searchMsgExecutionCaller, args ...string) map[string]any {
t.Helper()
payload, err := executeSearchMsgResult(caller, args...)
if err != nil {
t.Fatal(err)
}
return payload
}
func executeSearchMsgResult(caller *searchMsgExecutionCaller, args ...string) (map[string]any, error) {
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+search-msg", "--yes"}, args...))
if err := root.Execute(); err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
return nil, err
}
return payload, nil
}
func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
caller := &searchMsgExecutionCaller{}
payload := executeSearchMsg(t, caller,
"--query", "周报",
"--chat-id", "cid-1,cid-2",
"--sender", "42,Dsender",
"--at-ids", "43,Dat",
"--is-at-me",
"--message-type", "text",
"--only-robot",
"--chat-type", "group",
"--start", "2026-07-01T00:00:00+08:00",
"--end", "2026-07-02T00:00:00+08:00",
"--page-size", "50",
"--page-token", "p0",
"--page-all",
"--page-limit", "3",
)
if len(caller.calls) != 5 {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].tool != "get_conversation_info" || caller.calls[0].args["openConversationId"] != "cid-1" ||
caller.calls[1].tool != "get_conversation_info" || caller.calls[1].args["openConversationId"] != "cid-2" {
t.Fatalf("scope preflight calls = %#v", caller.calls[:2])
}
first := caller.calls[2]
if first.product != "im" || first.tool != "search_messages" {
t.Fatalf("first call = %#v", first)
}
for key, want := range map[string]any{
"senderUserIds": []string{"42"},
"senderOpenDingTakIds": []string{"Dsender"},
"atUserIds": []string{"43"},
"atOpenDingTakIds": []string{"Dat"},
"messageType": "text",
"onlyRobotMessages": true,
"searchConvType": "group",
} {
if !reflect.DeepEqual(first.args[key], want) {
t.Errorf("%s = %#v, want %#v", key, first.args[key], want)
}
}
if _, exists := first.args["openConversationIds"]; exists {
t.Fatalf("global fallback unexpectedly forwarded openConversationIds: %#v", first.args)
}
if caller.calls[3].args["cursor"] != "c2" {
t.Fatalf("second cursor = %#v", caller.calls[3].args["cursor"])
}
if ids := caller.calls[4].args["openMsgIds"]; !reflect.DeepEqual(ids, []string{"m1", "m2"}) {
t.Fatalf("mget ids = %#v", ids)
}
if payload["complete"] != true || payload["count"] != float64(2) ||
payload["pagesFetched"] != float64(2) || payload["enrichedCount"] != float64(2) ||
payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
messages, _ := payload["messages"].([]any)
firstMessage, _ := messages[0].(map[string]any)
if firstMessage["text"] != "detail-1" {
t.Fatalf("enriched message = %#v", firstMessage)
}
scope, _ := payload["scope"].(map[string]any)
if scope["targetsValidated"] != true || scope["filterMode"] != "client" || scope["resultsWithinScope"] != true {
t.Fatalf("scope = %#v", scope)
}
}
func TestCrossPlatformCoverageSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) {
caller := &searchMsgExecutionCaller{failSecondPage: true}
payload := executeSearchMsg(t, caller,
"--query", "周报",
"--page-all",
"--no-enrich",
)
if payload["complete"] != false || payload["count"] != float64(1) ||
payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
failures, _ := payload["failures"].([]any)
failure, _ := failures[0].(map[string]any)
if failure["stage"] != "search-page" {
t.Fatalf("failure = %#v", failure)
}
}
func TestCrossPlatformCoverageSearchMsgEnrichmentFailureKeepsSearchHits(t *testing.T) {
caller := &searchMsgExecutionCaller{failEnrichment: true}
payload := executeSearchMsg(t, caller, "--query", "周报")
if payload["complete"] != false || payload["count"] != float64(1) ||
payload["enrichedCount"] != float64(0) || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageSearchMsgMissingPaginationCannotClaimComplete(t *testing.T) {
caller := &searchMsgExecutionCaller{omitPagination: true}
payload := executeSearchMsg(t, caller, "--query", "周报", "--no-enrich")
if payload["complete"] != false || payload["count"] != float64(1) ||
payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
failures, _ := payload["failures"].([]any)
failure, _ := failures[0].(map[string]any)
if failure["stage"] != "search-pagination" {
t.Fatalf("failure = %#v", failure)
}
}
func TestCrossPlatformCoverageSearchMsgNumericZeroCursorIsComplete(t *testing.T) {
caller := &searchMsgExecutionCaller{numericZeroEnd: true}
payload := executeSearchMsg(t, caller, "--query", "周报", "--no-enrich")
if payload["complete"] != true || payload["hasMore"] != false || payload["paginationKnown"] != true {
t.Fatalf("numeric zero cursor pagination = %#v", payload)
}
if payload["nextCursor"] != "" {
t.Fatalf("numeric zero cursor exposed as next page: %#v", payload["nextCursor"])
}
}
func TestCrossPlatformCoverageSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) {
caller := &searchMsgExecutionCaller{omitMgetItem: true}
payload := executeSearchMsg(t, caller, "--query", "周报", "--page-all")
if payload["complete"] != false || payload["count"] != float64(2) ||
payload["enrichedCount"] != float64(1) || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
failures, _ := payload["failures"].([]any)
failure, _ := failures[0].(map[string]any)
if failure["stage"] != "message-enrichment" {
t.Fatalf("failure = %#v", failure)
}
if missing, _ := failure["missingMessageIds"].([]any); len(missing) != 1 || missing[0] != "m2" {
t.Fatalf("failure = %#v", failure)
}
}
func TestSearchMsgScopedFallbackFiltersGlobalResults(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{
"result": {
"conversationMessagesList": [
{"openConversationId":"cid-target","title":"目标群","messages":[{"openMessageId":"m-target","content":"目标"}]},
{"openConversationId":"cid-other","title":"其他群","messages":[{"openMessageId":"m-other","content":"越界"}]}
],
"hasMore": false
}
}`}
payload := executeSearchMsg(t, caller, "--group", "cid-target", "--query", "目标", "--no-enrich")
if payload["count"] != float64(1) || payload["complete"] != true {
t.Fatalf("payload = %#v", payload)
}
messages, _ := payload["messages"].([]any)
message, _ := messages[0].(map[string]any)
if message["conversationId"] != "cid-target" || message["messageId"] != "m-target" {
t.Fatalf("messages = %#v", messages)
}
if len(caller.calls) != 2 || caller.calls[0].tool != "get_conversation_info" || caller.calls[1].tool != "search_messages" {
t.Fatalf("calls = %#v", caller.calls)
}
if _, exists := caller.calls[1].args["openConversationIds"]; exists {
t.Fatalf("global fallback unexpectedly forwarded scope: %#v", caller.calls[1].args)
}
}
func TestSearchMsgScopedValidEmptyResultIsComplete(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--group", "cid-empty", "--query", "不存在", "--no-enrich")
if payload["count"] != float64(0) || payload["complete"] != true {
t.Fatalf("payload = %#v", payload)
}
scope, _ := payload["scope"].(map[string]any)
if scope["targetsValidated"] != true || scope["sourceComplete"] != true {
t.Fatalf("scope = %#v", scope)
}
}
func TestSearchMsgScopedEmptyPartialScanCannotClaimComplete(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{
"result": {
"conversationMessagesList": [
{"openConversationId":"cid-other","messages":[{"openMessageId":"m-other"}]}
],
"hasMore": true,
"nextCursor": "c2"
}
}`}
payload := executeSearchMsg(t, caller,
"--group", "cid-target", "--query", "周报", "--no-enrich", "--page-limit", "1")
if payload["count"] != float64(0) || payload["complete"] != false || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
scope, _ := payload["scope"].(map[string]any)
if scope["sourceComplete"] != false {
t.Fatalf("scope = %#v", scope)
}
failures, _ := payload["failures"].([]any)
failure, _ := failures[0].(map[string]any)
if failure["stage"] != "search-page-limit" {
t.Fatalf("failure = %#v", failure)
}
}
func TestSearchMsgInvalidCIDStopsBeforeGlobalSearch(t *testing.T) {
caller := &searchMsgExecutionCaller{failPreflight: true}
_, err := executeSearchMsgResult(caller, "--group", "cid-invalid", "--query", "周报", "--no-enrich")
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].tool != "get_conversation_info" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageSearchMsgPreservesAmbiguousPreflightMCPToolErrors(t *testing.T) {
for _, test := range []struct {
name string
want *helpers.CLIError
}{
{
name: "rate limited",
want: &helpers.CLIError{
Code: helpers.CodeMCPToolError,
Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`,
},
},
{
name: "permission denied",
want: &helpers.CLIError{
Code: helpers.CodeMCPToolError,
Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`,
},
},
} {
t.Run(test.name, func(t *testing.T) {
caller := &searchMsgExecutionCaller{preflightError: test.want}
_, err := executeSearchMsgResult(caller,
"--group", "cid-target", "--query", "周报", "--no-enrich")
if err != test.want {
t.Fatalf("error = %#v, want original %#v", err, test.want)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_conversation_info" {
t.Fatalf("calls = %#v", caller.calls)
}
})
}
}
func TestSearchMsgMissingConversationIdentityFailsClosed(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[{"openMessageId":"m1","content":"unknown"}],"hasMore":false}}`}
_, err := executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报", "--no-enrich")
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 TestCrossPlatformCoverageSearchMsgScopeFailureBranches(t *testing.T) {
want := errors.New("preflight unavailable")
caller := &searchMsgExecutionCaller{preflightError: want}
_, err := executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报", "--no-enrich")
if !errors.Is(err, want) {
t.Fatalf("preflight error = %v, want %v", err, want)
}
caller = &searchMsgExecutionCaller{
searchResponse: `{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target","content":"sparse"}],"hasMore":false}}`,
missingMgetCID: true,
}
_, err = executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_unverified" {
t.Fatalf("enrichment scope error = %#v", err)
}
err = searchScopeViolationError(
[]string{"cid-target"},
[]map[string]any{{"openMessageId": "m-empty"}, {"openMessageId": "m-other", "openConversationId": "cid-other"}},
)
typed = nil
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_violation" {
t.Fatalf("scope violation error = %#v", err)
}
}
func TestSearchMsgEnrichmentCannotMoveMessageOutsideScope(t *testing.T) {
caller := &searchMsgExecutionCaller{
searchResponse: `{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target","content":"sparse"}],"hasMore":false}}`,
wrongMgetScope: true,
}
_, err := executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报")
if err == nil {
t.Fatal("out-of-scope enrichment unexpectedly succeeded")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_violation" {
t.Fatalf("error = %#v", err)
}
}
func TestCrossPlatformCoverageSearchMsgLarkTimeAliasesAndAscendingOrder(t *testing.T) {
caller := &searchMsgExecutionCaller{
firstResponse: `{"result":{"messages":[{"openMessageId":"m2","createTime":1782892800000,"content":"later"},{"openMessageId":"m1","createTime":1782806400000,"content":"earlier"}],"hasMore":false}}`,
}
payload := executeSearchMsg(t, caller,
"--query", "周报",
"--start-time", "2026-07-01T00:00:00+08:00",
"--end-time", "2026-07-03T00:00:00+08:00",
"--sort", "asc",
"--no-enrich",
)
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
wantStart, _ := time.Parse(time.RFC3339, "2026-07-01T00:00:00+08:00")
wantEnd, _ := time.Parse(time.RFC3339, "2026-07-03T00:00:00+08:00")
if caller.calls[0].args["startTime"] != wantStart.UnixMilli() ||
caller.calls[0].args["endTime"] != wantEnd.UnixMilli() {
t.Fatalf("time params = %#v", caller.calls[0].args)
}
messages := payload["messages"].([]any)
if messages[0].(map[string]any)["messageId"] != "m1" ||
messages[1].(map[string]any)["messageId"] != "m2" {
t.Fatalf("ascending messages = %#v", messages)
}
rangeMeta := payload["queryRange"].(map[string]any)
if rangeMeta["order"] != "asc" || rangeMeta["semantics"] != "[start,end)" {
t.Fatalf("queryRange = %#v", rangeMeta)
}
}