263 lines
9.3 KiB
Go
263 lines
9.3 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 chat
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
|
)
|
|
|
|
func TestCrossPlatformCoverageConversationListTopProjectNormalizesAndFiltersType(t *testing.T) {
|
|
data := map[string]any{
|
|
"result": map[string]any{
|
|
"items": []any{
|
|
map[string]any{
|
|
"openConversationId": "cid-direct",
|
|
"title": "张三",
|
|
"singleChat": true,
|
|
},
|
|
map[string]any{
|
|
"openConversationId": "cid-group",
|
|
"title": "项目群",
|
|
"singleChat": false,
|
|
},
|
|
map[string]any{
|
|
"openConversationId": "cid-legacy",
|
|
"title": "旧版单聊",
|
|
"conversationType": "P2P",
|
|
},
|
|
map[string]any{
|
|
"openConversationId": "cid-unknown",
|
|
"title": "未知类型",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
all := conversationListTopProject(data)
|
|
if len(all) != 4 {
|
|
t.Fatalf("all conversations = %#v", all)
|
|
}
|
|
if got := all[0]["conversationType"]; got != "direct" {
|
|
t.Errorf("singleChat=true type = %#v, want direct", got)
|
|
}
|
|
if got := all[1]["conversationType"]; got != "group" {
|
|
t.Errorf("singleChat=false type = %#v, want group", got)
|
|
}
|
|
if got := all[2]["conversationType"]; got != "direct" {
|
|
t.Errorf("legacy P2P type = %#v, want direct", got)
|
|
}
|
|
if _, ok := all[3]["conversationType"]; ok {
|
|
t.Errorf("unknown type was fabricated: %#v", all[3])
|
|
}
|
|
|
|
if got, want := conversationListTopFilter(all, "group"), []map[string]any{all[1]}; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("group filter = %#v, want %#v", got, want)
|
|
}
|
|
if got, want := conversationListTopFilter(all, "direct"), []map[string]any{all[0], all[2]}; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("direct filter = %#v, want %#v", got, want)
|
|
}
|
|
if got := conversationListTopFilter(all, "all"); !reflect.DeepEqual(got, all) {
|
|
t.Errorf("all filter changed rows: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageConversationListTopRejectsInvalidType(t *testing.T) {
|
|
fake := &platformCoverageCaller{}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs([]string{"chat", "+conversation-list-top", "--type", "bot"})
|
|
if err := root.Execute(); err == nil {
|
|
t.Fatal("invalid --type unexpectedly succeeded")
|
|
}
|
|
if fake.tool != "" {
|
|
t.Fatalf("invalid --type reached lower tool %s/%s", fake.product, fake.tool)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageConversationListProjectUnwrapsGatewayTuple(t *testing.T) {
|
|
data := map[string]any{
|
|
"result": []any{
|
|
[]any{map[string]any{"openConversationId": "cid-1", "title": "项目群"}},
|
|
float64(2),
|
|
true,
|
|
},
|
|
}
|
|
if got := conversationListProject(data); len(got) != 1 || got[0]["openConversationId"] != "cid-1" {
|
|
t.Fatalf("conversation tuple projection = %#v", got)
|
|
}
|
|
if got := conversationListTopProject(data); len(got) != 1 || got[0]["openConversationId"] != "cid-1" {
|
|
t.Fatalf("top tuple projection = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageConversationListPageAllFollowsTypedCursor(t *testing.T) {
|
|
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
|
"im/list_all_conversations": {
|
|
`{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"}],"hasMore":true,"nextCursor":2}}`,
|
|
`{"result":{"conversationList":[{"openConversationId":"cid-2","title":"二"}],"hasMore":false}}`,
|
|
},
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs([]string{"chat", "+conversation-list", "--page-all"})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != int64(2) {
|
|
t.Fatalf("calls = %#v", fake.calls)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageConversationListSinglePagePreservesTypedCursor(t *testing.T) {
|
|
fake := &larkAlignmentCaller{responses: map[string]string{
|
|
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"}],"hasMore":true,"nextCursor":2}}`,
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
var output bytes.Buffer
|
|
root.SetOut(&output)
|
|
root.SetArgs([]string{"chat", "+conversation-list"})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(fake.calls) != 1 {
|
|
t.Fatalf("calls = %#v, want exactly one page", fake.calls)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["hasMore"] != true || payload["nextCursor"] != float64(2) {
|
|
t.Fatalf("pagination payload = %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageConversationListMaxItemsPublishesStableTruncation(t *testing.T) {
|
|
fake := &larkAlignmentCaller{responses: map[string]string{
|
|
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`,
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
var output bytes.Buffer
|
|
root.SetOut(&output)
|
|
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1", "--page-delay", "0"})
|
|
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["count"] != float64(1) || payload["truncated"] != true ||
|
|
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
|
t.Fatalf("payload = %#v", payload)
|
|
}
|
|
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != float64(2) {
|
|
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageConversationListRejectsOversizedLimitPage(t *testing.T) {
|
|
fake := &larkAlignmentCaller{responses: map[string]string{
|
|
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"},{"openConversationId":"cid-2"}],"hasMore":true,"nextCursor":2}}`,
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
var output bytes.Buffer
|
|
root.SetOut(&output)
|
|
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1"})
|
|
if err := root.Execute(); err == nil {
|
|
t.Fatal("oversized lower page unexpectedly published a safe continuation")
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) {
|
|
t.Fatalf("payload = %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageConversationListPropagatesDelayCancellation(t *testing.T) {
|
|
fake := &larkAlignmentCaller{responses: map[string]string{
|
|
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`,
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
root.SetContext(ctx)
|
|
var output bytes.Buffer
|
|
root.SetOut(&output)
|
|
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--page-delay", "1"})
|
|
if err := root.Execute(); err == nil || err != context.Canceled {
|
|
t.Fatalf("delay cancellation error = %v, want context.Canceled", err)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
|
|
t.Fatalf("payload = %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageConversationListAutoPageValidationAndOutputFailure(t *testing.T) {
|
|
helpers.InitDeps(&larkAlignmentCaller{})
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs([]string{"chat", "+conversation-list", "--max-items", "1"})
|
|
if err := root.Execute(); err == nil {
|
|
t.Fatal("max-items without page-all unexpectedly succeeded")
|
|
}
|
|
|
|
fake := &larkAlignmentCaller{responses: map[string]string{
|
|
"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":false}}`,
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root = newPlatformCoverageRoot()
|
|
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
|
|
root.SetArgs([]string{"chat", "+conversation-list"})
|
|
if err := root.Execute(); err == nil {
|
|
t.Fatal("output error was swallowed")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) {
|
|
fake := &larkAlignmentCaller{responses: map[string]string{
|
|
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`,
|
|
}}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
var output bytes.Buffer
|
|
root.SetOut(&output)
|
|
root.SetArgs([]string{"chat", "+conversation-list"})
|
|
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["count"] != float64(1) {
|
|
t.Fatalf("deduplicated payload = %#v", payload)
|
|
}
|
|
}
|