// 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) } }