Files
dingtalk-workspace-cli/internal/shortcut/smart/gap_fill_test.go

240 lines
7.6 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package smart
import (
"bytes"
"encoding/json"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageSafeResourceQueryDownloadsStayReadOnly(t *testing.T) {
for _, command := range []shortcut.Shortcut{AtMe, ChatMessages, SearchMsg, ThreadReplies} {
if command.Risk != shortcut.RiskRead {
t.Errorf("%s risk contract = %q", command.Command, command.Risk)
}
}
}
func TestCrossPlatformCoverageAtMeEmptyResultKeepsMessagesAndItemsIterable(t *testing.T) {
caller := &platformCoverageCaller{}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+at-me", "--format", "json"})
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())
}
for _, key := range []string{"messages", "items"} {
rows, ok := payload[key].([]any)
if !ok || len(rows) != 0 {
t.Fatalf("%s = %#v, want empty array", key, payload[key])
}
}
}
func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t *testing.T) {
message := `{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@image\"}","quotedMessage":{"openMessageId":"quoted","content":"{\"fileId\":\"@quoted-file\"}"}}`
tests := []struct {
name string
tool string
response string
args []string
resultKey string
}{
{
name: "chat messages",
tool: "chat/list_conversation_message_v2",
response: `{"result":{"messages":[` + message + `]}}`,
args: []string{"chat", "+chat-messages", "--conversation-id", "cid"},
resultKey: "messages",
},
{
name: "search",
tool: "im/search_messages",
response: `{"result":{"messages":[` + message + `],"hasMore":false}}`,
args: []string{"chat", "+search-msg", "--query", "x", "--no-enrich"},
resultKey: "messages",
},
{
name: "at me",
tool: "chat/search_at_me_message",
response: `{"result":{"conversationMessagesList":[{"openConversationId":"cid","messages":[` + message + `]}]}}`,
args: []string{"chat", "+at-me"},
resultKey: "messages",
},
{
name: "thread replies",
tool: "chat/list_topic_replies",
response: `{"result":{"messages":[` + message + `]}}`,
args: []string{"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread"},
resultKey: "replies",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
tc.tool: {tc.response},
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
args := append([]string{}, tc.args...)
args = append(args, "--download-resources", "--output-dir", "./downloads", "--dry-run")
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.counts[tc.tool] != 1 {
t.Fatalf("lower calls = %#v", caller.counts)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
rows, ok := payload[tc.resultKey].([]any)
if !ok || len(rows) != 1 {
t.Fatalf("payload missing %s: %#v", tc.resultKey, payload)
}
row, _ := rows[0].(map[string]any)
resources, _ := row["resourceRefs"].([]any)
if len(resources) != 2 {
t.Fatalf("visible resources = %#v", resources)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
if ledger["dryRun"] != true ||
ledger["discoveredCount"] != float64(2) ||
ledger["requestedCount"] != float64(2) {
t.Fatalf("resource plan = %#v", ledger)
}
})
}
}
func TestCrossPlatformCoverageMessageReadShortcutResourceOutputValidation(t *testing.T) {
for _, args := range [][]string{
{"chat", "+chat-messages", "--conversation-id", "cid"},
{"chat", "+search-msg", "--query", "x", "--no-enrich"},
{"chat", "+at-me"},
{"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread"},
} {
helpers.InitDeps(&smartCoverageCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append(args, "--download-resources", "--output-dir", "../outside", "--yes"))
if err := root.Execute(); err == nil {
t.Fatalf("unsafe output accepted: %v", args)
}
}
}
func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T) {
caller := &platformCoverageCaller{}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
before := time.Now().Add(-2 * time.Second)
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
after := time.Now().Add(2 * time.Second)
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.args["forward"] != false {
t.Fatalf("default forward = %#v, want false", call.args["forward"])
}
boundary, err := time.ParseInLocation(
"2006-01-02 15:04:05",
call.args["time"].(string),
dingTalkMessageLocation,
)
if err != nil {
t.Fatal(err)
}
if boundary.Before(before) || boundary.After(after) {
t.Fatalf("default time = %s, want current boundary", boundary)
}
}
func TestCrossPlatformCoverageFormatDingTalkMessageBoundaryDoesNotDependOnProcessTimezone(t *testing.T) {
now := time.Date(2026, time.July, 29, 1, 2, 3, 0, time.UTC)
if got := formatDingTalkMessageBoundary(now); got != "2026-07-29 09:02:03" {
t.Fatalf("UTC process boundary = %q, want DingTalk UTC+8 wall time", got)
}
}
func TestCrossPlatformCoverageChatMessagesPreservesExplicitTime(t *testing.T) {
caller := &platformCoverageCaller{}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-messages",
"--conversation-id", "cid",
"--time", "2026-07-01 12:34:56",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 ||
caller.calls[0].args["time"] != "2026-07-01 12:34:56" {
t.Fatalf("explicit time call = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageSearchMsgFlattensRealGroupedResponse(t *testing.T) {
original := map[string]any{"openMessageId": "m1"}
items := searchMsgItems(map[string]any{
"result": map[string]any{
"conversationMessagesList": []any{
"invalid-group",
map[string]any{"messages": "invalid"},
map[string]any{
"openConversationId": "cid",
"title": "会话",
"singleChat": true,
"messages": []any{
"invalid-message",
original,
map[string]any{
"openMessageId": "m2",
"openConversationId": "own-cid",
"conversationTitle": "own-title",
"singleChat": false,
},
},
},
},
},
})
if len(items) != 2 {
t.Fatalf("grouped items = %#v", items)
}
if items[0]["openConversationId"] != "cid" || items[0]["conversationTitle"] != "会话" ||
items[0]["singleChat"] != true {
t.Fatalf("injected group context = %#v", items[0])
}
if items[1]["openConversationId"] != "own-cid" || items[1]["conversationTitle"] != "own-title" ||
items[1]["singleChat"] != false {
t.Fatalf("message context was overwritten: %#v", items[1])
}
if _, mutated := original["openConversationId"]; mutated {
t.Fatalf("source message mutated: %#v", original)
}
if searchMsgItems(map[string]any{"result": "invalid"}) != nil {
t.Fatal("non-map child was accepted")
}
}