329 lines
12 KiB
Go
329 lines
12 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
package chat
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
|
)
|
|
|
|
func TestCrossPlatformCoverageChatSearchDryRunStopsBeforeRead(t *testing.T) {
|
|
fake := &larkAlignmentCaller{dryRun: true}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs([]string{
|
|
"chat", "+chat-search", "--query", "项目", "--page-size", "20",
|
|
"--cursor", "cursor-1", "--exclude-muted", "--dry-run",
|
|
})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(fake.calls) != 0 {
|
|
t.Fatalf("chat-search dry-run made lower calls: %#v", fake.calls)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageChatSearchPageAllUsesOpaqueCursorAndDeduplicates(t *testing.T) {
|
|
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
|
"im/search_groups": {
|
|
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"}],"hasMore":true,"nextCursor":"cursor-2"}}`,
|
|
`{"result":{"groups":[{"openConversationId":"cid-1","title":"重复项目群"},{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":false}}`,
|
|
},
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
var output bytes.Buffer
|
|
root.SetOut(&output)
|
|
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all", "--page-limit", "5"})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(fake.calls) != 2 || fake.calls[0].args["cursor"] != "0" || fake.calls[1].args["cursor"] != "cursor-2" {
|
|
t.Fatalf("calls = %#v", fake.calls)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) || payload["complete"] != true || payload["hasMore"] != false {
|
|
t.Fatalf("payload = %#v", payload)
|
|
}
|
|
chats := payload["chats"].([]any)
|
|
if chats[0].(map[string]any)["name"] != "项目一群" || chats[1].(map[string]any)["openConversationId"] != "cid-2" {
|
|
t.Fatalf("chats = %#v", chats)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageChatSearchProbesMaximumWindowWhenBackendFalselyEndsFullFirstPage(t *testing.T) {
|
|
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
|
"im/search_groups": {
|
|
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"}],"hasMore":false}}`,
|
|
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"},{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":false}}`,
|
|
},
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
var output bytes.Buffer
|
|
root.SetOut(&output)
|
|
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all", "--page-limit", "5"})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(fake.calls) != 2 || fake.calls[0].args["limit"] != 1 || fake.calls[1].args["limit"] != 100 {
|
|
t.Fatalf("calls = %#v", fake.calls)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["count"] != float64(2) || payload["complete"] != true || payload["paginationKnown"] != false ||
|
|
payload["completionEvidence"] != "max_window_short_page" || payload["stopReason"] != "max_window_short_page" {
|
|
t.Fatalf("payload = %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageChatSearchPageTokenAndPageLimit(t *testing.T) {
|
|
fake := &larkAlignmentCaller{responses: map[string]string{
|
|
"im/search_groups": `{"result":{"groups":[{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":true,"nextCursor":"cursor-3"}}`,
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
var output bytes.Buffer
|
|
root.SetOut(&output)
|
|
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-token", "cursor-2", "--page-all", "--page-limit", "1"})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(fake.calls) != 1 || fake.calls[0].args["cursor"] != "cursor-2" {
|
|
t.Fatalf("calls = %#v", fake.calls)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["complete"] != false || payload["hasMore"] != true || payload["nextCursor"] != "cursor-3" ||
|
|
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
|
|
t.Fatalf("payload = %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageChatSearchFailureModes(t *testing.T) {
|
|
t.Run("later read failure keeps partial result", func(t *testing.T) {
|
|
fake := &larkAlignmentCaller{
|
|
sequenceResponses: map[string][]string{
|
|
"im/search_groups": {`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目群"}],"hasMore":true,"nextCursor":"cursor-2"}}`},
|
|
},
|
|
failProductToolAt: map[string]int{"im/search_groups": 2},
|
|
}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
var output bytes.Buffer
|
|
root.SetOut(&output)
|
|
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-all"})
|
|
if err := root.Execute(); err == nil {
|
|
t.Fatal("expected later-page error")
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["count"] != float64(1) || payload["partial"] != true || payload["stopReason"] != "read_failure" {
|
|
t.Fatalf("payload = %#v", payload)
|
|
}
|
|
})
|
|
|
|
t.Run("full legacy page without pagination fails closed", func(t *testing.T) {
|
|
fake := &larkAlignmentCaller{responses: map[string]string{
|
|
"im/search_groups": `{"result":{"groups":[{"openConversationId":"cid-1","title":"项目群"}]}}`,
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
root.SetOut(&bytes.Buffer{})
|
|
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all"})
|
|
if err := root.Execute(); err == nil {
|
|
t.Fatal("missing pagination unexpectedly succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("short legacy page is bounded complete", func(t *testing.T) {
|
|
fake := &larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
var output bytes.Buffer
|
|
root.SetOut(&output)
|
|
root.SetArgs([]string{"chat", "+chat-search", "--query", "不存在"})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["complete"] != true || payload["paginationKnown"] != false || payload["stopReason"] != "legacy_short_page" {
|
|
t.Fatalf("payload = %#v", payload)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageChatSearchPaginationValidation(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"--page-size", "0"},
|
|
{"--page-limit", "2"},
|
|
{"--page-all", "--page-limit", "0"},
|
|
{"--page-all", "--page-limit", "501"},
|
|
{"--cursor", "c1", "--page-token", "c2"},
|
|
} {
|
|
helpers.InitDeps(&larkAlignmentCaller{})
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs(append([]string{"chat", "+chat-search", "--query", "项目"}, args...))
|
|
if err := root.Execute(); err == nil {
|
|
t.Fatalf("invalid args succeeded: %v", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageChatSearchAdditionalPaginationEdges(t *testing.T) {
|
|
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
|
|
t.Helper()
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
var output bytes.Buffer
|
|
root.SetOut(&output)
|
|
root.SetArgs(append([]string{"chat", "+chat-search", "--query", "项目"}, args...))
|
|
err := root.Execute()
|
|
if output.Len() == 0 {
|
|
return nil, err
|
|
}
|
|
var payload map[string]any
|
|
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
|
|
t.Fatal(decodeErr)
|
|
}
|
|
return payload, err
|
|
}
|
|
|
|
t.Run("first read failure", func(t *testing.T) {
|
|
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/search_groups": 1}})
|
|
if err == nil {
|
|
t.Fatal("first read failure unexpectedly succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("cursor without hasMore continues", func(t *testing.T) {
|
|
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
|
"im/search_groups": {
|
|
`{"result":{"groups":[{"openConversationId":"g1"}],"nextCursor":"next"}}`,
|
|
`{"result":{"groups":[],"hasMore":false}}`,
|
|
},
|
|
}}, "--page-all")
|
|
if err != nil || payload["complete"] != true || payload["pagesFetched"] != float64(2) {
|
|
t.Fatalf("payload=%#v err=%v", payload, err)
|
|
}
|
|
})
|
|
|
|
t.Run("single full page is untrusted", func(t *testing.T) {
|
|
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
|
|
"im/search_groups": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":false}}`,
|
|
}}, "--page-size", "1")
|
|
if err != nil || payload["complete"] != false || payload["stopReason"] != "single_page_full_untrusted" {
|
|
t.Fatalf("payload=%#v err=%v", payload, err)
|
|
}
|
|
})
|
|
|
|
t.Run("bounded probe reports page limit", func(t *testing.T) {
|
|
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
|
|
"im/search_groups": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":false}}`,
|
|
}}, "--page-size", "1", "--page-all", "--page-limit", "1")
|
|
if err != nil || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
|
|
t.Fatalf("payload=%#v err=%v", payload, err)
|
|
}
|
|
})
|
|
|
|
t.Run("full maximum probe fails closed", func(t *testing.T) {
|
|
groups := make([]map[string]any, chatSearchMaxWindowSize)
|
|
for i := range groups {
|
|
groups[i] = map[string]any{"openConversationId": fmt.Sprintf("probe-%d", i)}
|
|
}
|
|
second, marshalErr := json.Marshal(map[string]any{"result": map[string]any{"groups": groups, "hasMore": false}})
|
|
if marshalErr != nil {
|
|
t.Fatal(marshalErr)
|
|
}
|
|
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
|
"im/search_groups": {
|
|
`{"result":{"groups":[{"openConversationId":"first"}],"hasMore":false}}`,
|
|
string(second),
|
|
},
|
|
}}, "--page-size", "1", "--page-all")
|
|
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
|
|
t.Fatalf("payload=%#v err=%v", payload, err)
|
|
}
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
response string
|
|
args []string
|
|
wantErr bool
|
|
stop string
|
|
}{
|
|
{name: "missing continuation", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true}}`, wantErr: true, stop: "pagination_error"},
|
|
{name: "single page continuation", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":"next"}}`, stop: "single_page"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/search_groups": tc.response}, failProductToolAt: map[string]int{}}, tc.args...)
|
|
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
|
|
t.Fatalf("payload=%#v err=%v", payload, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("exclude muted reaches lower call and output errors propagate", func(t *testing.T) {
|
|
fake := &larkAlignmentCaller{responses: map[string]string{
|
|
"im/search_groups": `{"result":{"groups":[],"hasMore":false}}`,
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
|
|
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--exclude-muted"})
|
|
if err := root.Execute(); err == nil || fake.calls[0].args["excludeMuted"] != true {
|
|
t.Fatalf("calls=%#v err=%v", fake.calls, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageChatSearchProjectionEdges(t *testing.T) {
|
|
if got := chatSearchItems(nil); len(got) != 0 {
|
|
t.Fatalf("nil items = %#v", got)
|
|
}
|
|
if got := chatSearchItems(map[string]any{"result": map[string]any{"unknown": true}}); len(got) != 0 {
|
|
t.Fatalf("unknown items = %#v", got)
|
|
}
|
|
got := projectChatSearchItems([]any{
|
|
"invalid",
|
|
map[string]any{"conversationId": "g1", "conversationName": "项目群"},
|
|
})
|
|
if len(got) != 1 || got[0]["openConversationId"] != "g1" || got[0]["name"] != "项目群" {
|
|
t.Fatalf("projected = %#v", got)
|
|
}
|
|
for _, value := range []any{nil, "", "0", "<nil>"} {
|
|
if cursor := chatSearchCursorString(value); cursor != "" {
|
|
t.Fatalf("cursor %v = %q", value, cursor)
|
|
}
|
|
}
|
|
|
|
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
|
|
"im/search_groups": `{"result":{"groups":[],"hasMore":false}}`,
|
|
}})
|
|
root := newPlatformCoverageRoot()
|
|
root.SetOut(&bytes.Buffer{})
|
|
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--cursor="})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|