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

202 lines
6.9 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"
"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
}
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 != "im" {
return nil, errors.New("unexpected product")
}
switch tool {
case "search_messages":
if f.omitPagination {
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}]}}`), nil
}
if args["cursor"] == "c2" {
if f.failSecondPage {
return nil, errors.New("second page unavailable")
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","content":"sparse-2"}],"hasMore":false}}`), nil
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
case "list_messages_by_ids":
if f.failEnrichment {
return nil, errors.New("mget unavailable")
}
if f.omitMgetItem {
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"}]}`), 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()
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 {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
return payload
}
func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(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) != 3 {
t.Fatalf("calls = %#v", caller.calls)
}
first := caller.calls[0]
if first.product != "im" || first.tool != "search_messages" {
t.Fatalf("first call = %#v", first)
}
for key, want := range map[string]any{
"openConversationIds": []string{"cid-1", "cid-2"},
"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 caller.calls[1].args["cursor"] != "c2" {
t.Fatalf("second cursor = %#v", caller.calls[1].args["cursor"])
}
if ids := caller.calls[2].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)
}
}
func TestSearchMsgLaterPageFailurePublishesPartialLedger(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 TestSearchMsgEnrichmentFailureKeepsSearchHits(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 TestSearchMsgMissingPaginationCannotClaimComplete(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 TestSearchMsgMissingMgetItemPublishesFailureLedger(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)
}
}