Files
dingtalk-workspace-cli/internal/shortcut/chat/remaining_read_pagination_test.go
T

485 lines
18 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageChatListAllPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_my_groups_pagination": {
`{"result":{"groups":[{"openConversationId":"g1","title":"群一"}],"hasMore":true,"nextCursor":88}}`,
`{"result":{"groups":[{"openConversationId":"g1","title":"重复群"},{"openConversationId":"g2","title":"群二"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--limit", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != float64(88) {
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 || payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageDirectMessagesPageAllUsesMillisecondCursor(t *testing.T) {
const cursorMillis int64 = 1785919699136
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"chat/list_individual_chat_message": {
`{"result":{"messages":[{"openMessageId":"m2","createTime":"2026-08-05 16:48:19"}],"hasMore":true,"nextCursor":1785919699136}}`,
`{"result":{"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:19"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--open-dingtalk-id", fixtureCurrentDOpenID,
"--time", "2026-08-05 16:49:00", "--forward=false", "--limit", "1",
"--page-all", "--page-limit", "5",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
if len(fake.calls) != 2 || fake.calls[1].args["time"] != wantBoundary {
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["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageDirectMessagesPageLimitPublishesExecutableContinuation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00",
"--page-all", "--page-limit", "1",
})
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"] != false || payload["hasMore"] != true ||
payload["truncated"] != true || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListAllMaxItemsPublishesStableTruncation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--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(88) {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
}
}
func TestCrossPlatformCoverageChatListAllFailsClosedOnOversizeAndCanceledDelay(t *testing.T) {
t.Run("oversized lower page", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"},{"openConversationId":"g2"}],"hasMore":true,"nextCursor":88}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--max-items", "1"})
if err := root.Execute(); err == nil {
t.Fatal("oversized lower page unexpectedly published a 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) {
t.Fatalf("payload = %#v", payload)
}
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != nil {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
}
})
t.Run("canceled delay", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`,
}}
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", "+chat-list-all", "--page-all", "--page-delay", "1"})
if err := root.Execute(); err == nil {
t.Fatal("canceled delay unexpectedly succeeded")
}
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 TestCrossPlatformCoverageDirectMessagesPageAllFailsClosedWithoutMillisecondCursor(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00", "--page-all",
})
if err := root.Execute(); err == nil {
t.Fatal("missing direct-message cursor unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListAllLaterReadFailureKeepsPartialLedger(t *testing.T) {
fake := &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"im/list_my_groups_pagination": {`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`},
},
failProductToolAt: map[string]int{"im/list_my_groups_pagination": 2},
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("later group-list failure unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["complete"] != false || payload["partial"] != true ||
payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatRemainingPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"chat", "+chat-list-all", "--limit", "201"},
{"chat", "+chat-list-all", "--page-limit", "2"},
{"chat", "+chat-list-all", "--page-all", "--page-limit", "501"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-limit", "2"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-all", "--page-limit", "501"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
root := newPlatformCoverageRoot()
cmd, _, err := root.Find([]string{"chat", "+chat-list-all"})
if err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("max-items", "1"); err != nil {
t.Fatal(err)
}
if err := validateChatListAll(shortcut.RuntimeContextForTest(cmd, ChatListAll)); err == nil {
t.Fatal("direct auto-page validation unexpectedly succeeded")
}
}
func TestCrossPlatformCoverageChatListAllAdditionalEdges(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-list-all"}, 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("single page terminal and continuing", func(t *testing.T) {
for _, tc := range []struct {
name string
response string
stop string
}{
{name: "terminal", response: `{"result":{"groups":[],"hasMore":false}}`, stop: "source_complete"},
{name: "continuing", response: `{"result":{"groups":[],"hasMore":true,"nextCursor":2}}`, stop: "single_page"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_my_groups_pagination": tc.response}})
if err != nil || payload["stopReason"] != tc.stop || payload["pagesFetched"] != float64(1) {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
})
t.Run("first read failure", func(t *testing.T) {
for _, args := range [][]string{nil, {"--page-all"}} {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/list_my_groups_pagination": 1}}, args...)
if err == nil {
t.Fatalf("first read failure unexpectedly succeeded for %v", args)
}
}
})
for _, tc := range []struct {
name string
response string
args []string
wantErr bool
stop string
}{
{name: "unknown pagination", response: `{"result":{"groups":[]}}`, args: []string{"--page-all"}, wantErr: true, stop: "pagination_error"},
{name: "missing continuation", response: `{"result":{"groups":[],"hasMore":true}}`, args: []string{"--page-all"}, wantErr: true, stop: "pagination_error"},
{name: "page limit", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`, args: []string{"--page-all", "--page-limit", "1"}, stop: "page_limit"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_my_groups_pagination": tc.response}}, tc.args...)
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("output errors propagate", func(t *testing.T) {
for _, args := range [][]string{nil, {"--page-all"}} {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs(append([]string{"chat", "+chat-list-all"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("output error swallowed for args %v", args)
}
}
})
for _, value := range []any{nil, "", "0", "<nil>"} {
if got := chatListAllCursorString(value); got != "" {
t.Fatalf("cursor %v = %q", value, got)
}
}
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"title":"无 ID 群"}],"hasMore":false}}`,
}}, "--page-all")
if err != nil || payload["count"] != float64(1) {
t.Fatalf("missing-id payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageDirectMessagesAdditionalEdges(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", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00",
}, 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
}
for _, args := range [][]string{
{"chat", "+messages-list-direct", "--time", "2026-01-01"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--limit", "0"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--size", "0"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
t.Run("single page terminal and continuing", func(t *testing.T) {
for _, tc := range []struct {
name string
response string
stop string
}{
{name: "terminal", response: `{"result":{"messages":[],"hasMore":false}}`, stop: "source_complete"},
{name: "continuing", response: `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`, stop: "single_page"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"chat/list_individual_chat_message": tc.response}})
if err != nil || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
})
t.Run("read failures", func(t *testing.T) {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"chat/list_individual_chat_message": 1}})
if err == nil {
t.Fatal("single-page read failure unexpectedly succeeded")
}
_, err = run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"chat/list_individual_chat_message": 1}}, "--page-all")
if err == nil {
t.Fatal("first all-page read failure unexpectedly succeeded")
}
payload, err := run(t, &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"chat/list_individual_chat_message": {`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`},
},
failProductToolAt: map[string]int{"chat/list_individual_chat_message": 2},
}, "--page-all")
if err == nil || payload["partial"] != true || payload["stopReason"] != "read_failure" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
responses []string
}{
{name: "unknown pagination", responses: []string{`{"result":{"messages":[{"openMessageId":"m1"}]}}`}},
{name: "empty continuing page", responses: []string{`{"result":{"messages":[],"hasMore":true,"nextCursor":1785919699136}}`}},
{name: "stalled cursor and duplicate", responses: []string{
`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
}},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"chat/list_individual_chat_message": tc.responses,
}}, "--page-all")
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("all-page output errors propagate", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/list_individual_chat_message": `{"result":{"messages":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs([]string{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("output error was swallowed")
}
})
for _, tc := range []struct {
name string
value any
ok bool
}{
{name: "int", value: int(1), ok: true},
{name: "int32", value: int32(2), ok: true},
{name: "float32", value: float32(3), ok: true},
{name: "float64", value: float64(4), ok: true},
{name: "string", value: "5", ok: true},
{name: "fractional float32", value: float32(1.5)},
{name: "negative float64", value: float64(-1)},
{name: "invalid string", value: "invalid"},
{name: "unsupported", value: true},
{name: "zero", value: int64(0)},
} {
t.Run("cursor "+tc.name, func(t *testing.T) {
key, boundary, err := directMessageCursorBoundary(tc.value)
if tc.ok {
if err != nil || key == "" || boundary == "" {
t.Fatalf("cursor=(%q,%q,%v)", key, boundary, err)
}
return
}
if err == nil {
t.Fatalf("invalid cursor succeeded: (%q,%q)", key, boundary)
}
})
}
}