Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f7db84d47 | ||
|
|
a023a2956a | ||
|
|
e023be1444 | ||
|
|
7cc8db4eb0 |
+11
-1
@@ -6,6 +6,17 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed devapp list pagination** (#917) — the paginated `dws devapp`
|
||||
list shortcuts (`+list`, `+permission-list`, `+event-list`, and
|
||||
`+version-list`) now preserve `hasMore` and `nextCursor` with each single-page
|
||||
result, forward opaque cursors byte-for-byte (including surrounding or
|
||||
whitespace-only values), normalize deployed providers' terminal missing,
|
||||
null, empty, or last-observed cursor to `nextCursor: ""`, and reject malformed
|
||||
or ambiguous pagination pages and unprojectable app items instead of emitting
|
||||
terminal-looking partial results.
|
||||
|
||||
## [1.0.58-beta.2] - 2026-08-10
|
||||
|
||||
### Added
|
||||
@@ -32,7 +43,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
|
||||
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
|
||||
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
|
||||
|
||||
## [1.0.58-beta.1] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func executeDevAppListRoot(t *testing.T, args ...string) (string, string, error) {
|
||||
return executeDevAppRoot(t, "+list", args...)
|
||||
}
|
||||
|
||||
func executeDevAppRoot(t *testing.T, command string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, helpers.GetCaller())
|
||||
root := NewRootCommand()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs(append([]string{"devapp", command}, args...))
|
||||
err := root.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppPaginatedShortcutsMock(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
command string
|
||||
collection string
|
||||
args []string
|
||||
}{
|
||||
{name: "apps", command: "+list", collection: "apps"},
|
||||
{name: "permissions", command: "+permission-list", collection: "permissions", args: []string{"--unified-app-id", "X"}},
|
||||
{name: "events", command: "+event-list", collection: "events", args: []string{"--unified-app-id", "X"}},
|
||||
{name: "versions", command: "+version-list", collection: "versions", args: []string{"--unified-app-id", "X"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := append(append([]string(nil), test.args...), "--mock", "--format", "json")
|
||||
stdout, stderr, err := executeDevAppRoot(t, test.command, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("mock %s error = %v, stderr=%q", test.command, err, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("mock %s stderr = %q", test.command, stderr)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
|
||||
t.Fatalf("decode mock %s output: %v, output=%q", test.command, err, stdout)
|
||||
}
|
||||
items, ok := payload[test.collection].([]any)
|
||||
if len(payload) != 4 || !ok || len(items) != 0 || payload["count"] != float64(0) ||
|
||||
payload["hasMore"] != false || payload["nextCursor"] != "" {
|
||||
t.Fatalf("mock %s payload = %#v", test.command, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppListMockPaginationFormats(t *testing.T) {
|
||||
for _, format := range []string{"json", "raw", "ndjson", "pretty"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
if format == "pretty" {
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
}
|
||||
stdout, stderr, err := executeDevAppListRoot(t, "--mock", "--format", format)
|
||||
if err != nil {
|
||||
t.Fatalf("mock list error = %v, stderr=%q", err, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("mock list stderr = %q", stderr)
|
||||
}
|
||||
if format == "pretty" {
|
||||
values := map[string]string{}
|
||||
lines := strings.Split(strings.TrimSuffix(stdout, "\n"), "\n")
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("pretty mock lines = %d, want four: %q", len(lines), stdout)
|
||||
}
|
||||
for _, line := range lines {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("pretty mock line is not key/value: %q", line)
|
||||
}
|
||||
values[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if len(values) != 4 || values["apps"] != "[]" || values["count"] != "0" ||
|
||||
values["hasMore"] != "false" || values["nextCursor"] != "" {
|
||||
t.Fatalf("pretty mock values = %#v, output=%q", values, stdout)
|
||||
}
|
||||
return
|
||||
}
|
||||
if format == "ndjson" && len(strings.Split(strings.TrimSuffix(stdout, "\n"), "\n")) != 1 {
|
||||
t.Fatalf("ndjson mock output expanded envelope: %q", stdout)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
|
||||
t.Fatalf("decode mock output: %v, output=%q", err, stdout)
|
||||
}
|
||||
apps, ok := payload["apps"].([]any)
|
||||
if len(payload) != 4 || !ok || len(apps) != 0 || payload["count"] != float64(0) ||
|
||||
payload["hasMore"] != false || payload["nextCursor"] != "" {
|
||||
t.Fatalf("mock pagination payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type devAppListFixtureRunner struct {
|
||||
response string
|
||||
}
|
||||
|
||||
func (r devAppListFixtureRunner) Run(
|
||||
_ context.Context,
|
||||
invocation executor.Invocation,
|
||||
) (executor.Result, error) {
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": []any{map[string]any{"type": "text", "text": r.response}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppListInvalidContractJSONError(t *testing.T) {
|
||||
helpers.InitDepsForTest(t, helpers.GetCaller())
|
||||
testseam.Swap(t, &rootNewCommandRunnerWithFlags, func(*GlobalFlags) executor.Runner {
|
||||
return devAppListFixtureRunner{response: `{
|
||||
"apps":[{"unifiedAppId":"would-be-partial"},"payload-do-not-leak"],
|
||||
"hasMore":false,
|
||||
"nextCursor":""
|
||||
}`}
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
var stdout bytes.Buffer
|
||||
var cobraStderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&cobraStderr)
|
||||
root.SetArgs([]string{"devapp", "+list", "--cursor", "cursor-do-not-leak", "--format", "json"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("invalid pagination contract unexpectedly succeeded")
|
||||
}
|
||||
if stdout.Len() != 0 || cobraStderr.Len() != 0 {
|
||||
t.Fatalf("invalid contract leaked command output: stdout=%q stderr=%q", stdout.String(), cobraStderr.String())
|
||||
}
|
||||
if code := apperrors.ExitCode(err); code != 1 {
|
||||
t.Fatalf("exit code = %d, want 1", code)
|
||||
}
|
||||
|
||||
var machineStderr bytes.Buffer
|
||||
if printErr := printExecutionError(root, &stdout, &machineStderr, err); printErr != nil {
|
||||
t.Fatalf("print JSON error: %v", printErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("JSON error wrote stdout: %q", stdout.String())
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(machineStderr.String()))
|
||||
var payload map[string]any
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
t.Fatalf("decode stderr JSON: %v, stderr=%q", err, machineStderr.String())
|
||||
}
|
||||
if err := decoder.Decode(&map[string]any{}); err != io.EOF {
|
||||
t.Fatalf("stderr contained more than one JSON value: %v, stderr=%q", err, machineStderr.String())
|
||||
}
|
||||
errorPayload, ok := payload["error"].(map[string]any)
|
||||
message, messageOK := errorPayload["message"].(string)
|
||||
if len(payload) != 1 || !ok || len(errorPayload) != 4 ||
|
||||
errorPayload["category"] != "api" || errorPayload["code"] != float64(1) ||
|
||||
errorPayload["reason"] != "devapp_pagination_contract_invalid" ||
|
||||
!messageOK || strings.TrimSpace(message) == "" {
|
||||
t.Fatalf("JSON error contract = %#v", payload)
|
||||
}
|
||||
if strings.Contains(machineStderr.String(), "cursor-do-not-leak") ||
|
||||
strings.Contains(machineStderr.String(), "payload-do-not-leak") ||
|
||||
strings.Contains(machineStderr.String(), "would-be-partial") {
|
||||
t.Fatalf("JSON error leaked request or response data: %q", machineStderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppListMockIsolation(t *testing.T) {
|
||||
helpers.InitDepsForTest(t, helpers.GetCaller())
|
||||
runner := newCommandRunnerWithFlags(&GlobalFlags{Mock: true})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
product string
|
||||
tool string
|
||||
}{
|
||||
{name: "other devapp tool", product: "devapp", tool: "get_dev_app"},
|
||||
{name: "other product", product: "contact", tool: "list_contacts"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
invocation := executor.NewHelperInvocation(
|
||||
"mock.isolation", test.product, test.tool, map[string]any{},
|
||||
)
|
||||
result, err := runner.Run(context.Background(), invocation)
|
||||
if err != nil {
|
||||
t.Fatalf("mock invocation error = %v", err)
|
||||
}
|
||||
content, ok := result.Response["content"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("mock content = %#v", result.Response["content"])
|
||||
}
|
||||
mockResult, ok := content["result"].([]any)
|
||||
if !ok || len(mockResult) != 0 {
|
||||
t.Fatalf("non-target mock result changed = %#v", content["result"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -589,13 +589,29 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
// Mock mode: return predefined mock response without network call.
|
||||
if r.globalFlags != nil && r.globalFlags.Mock {
|
||||
invocation.Implemented = true
|
||||
result := any([]any{})
|
||||
if invocation.CanonicalProduct == "devapp" {
|
||||
collectionKey := map[string]string{
|
||||
"list_dev_app": "apps",
|
||||
"list_dev_app_permissions": "items",
|
||||
"list_dev_app_events": "events",
|
||||
"list_dev_app_versions": "items",
|
||||
}[invocation.Tool]
|
||||
if collectionKey != "" {
|
||||
result = map[string]any{
|
||||
collectionKey: []any{},
|
||||
"hasMore": false,
|
||||
"nextCursor": "",
|
||||
}
|
||||
}
|
||||
}
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"endpoint": transport.RedactURL(endpoint),
|
||||
"content": map[string]any{
|
||||
"success": true,
|
||||
"result": []any{},
|
||||
"result": result,
|
||||
"_mock": true,
|
||||
"_tool": invocation.Tool,
|
||||
},
|
||||
|
||||
@@ -30,17 +30,20 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const productDevApp = "devapp"
|
||||
|
||||
// applyCursor forwards --cursor/--page-size into params, matching the helper's
|
||||
// pass-through pagination (pageSize defaults to 20, cursor omitted on page 1).
|
||||
func applyCursor(rt *shortcut.RuntimeContext, params map[string]any) {
|
||||
// applyCursor forwards --cursor/--page-size into params and returns the opaque
|
||||
// request cursor for response-contract validation. pageSize defaults to 20 and
|
||||
// cursor is omitted on page 1.
|
||||
func applyCursor(rt *shortcut.RuntimeContext, params map[string]any) string {
|
||||
requestCursor := listAppRequestCursor(rt)
|
||||
if rt.Changed("cursor") {
|
||||
if cur := rt.Str("cursor"); cur != "" {
|
||||
params["cursor"] = cur
|
||||
if requestCursor != "" {
|
||||
params["cursor"] = requestCursor
|
||||
}
|
||||
}
|
||||
size := rt.Int("page-size")
|
||||
@@ -48,6 +51,15 @@ func applyCursor(rt *shortcut.RuntimeContext, params map[string]any) {
|
||||
size = 20
|
||||
}
|
||||
params["pageSize"] = size
|
||||
return requestCursor
|
||||
}
|
||||
|
||||
func listAppRequestCursor(rt *shortcut.RuntimeContext) string {
|
||||
if rt == nil || rt.Command() == nil {
|
||||
return ""
|
||||
}
|
||||
value, _ := rt.Command().Flags().GetString("cursor")
|
||||
return value
|
||||
}
|
||||
|
||||
var cursorFlags = []shortcut.Flag{
|
||||
@@ -105,7 +117,7 @@ var ListApp = shortcut.Shortcut{
|
||||
}, cursorFlags...),
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
applyCursor(rt, params)
|
||||
requestCursor := applyCursor(rt, params)
|
||||
if rt.Changed("name") {
|
||||
params["name"] = rt.Str("name")
|
||||
}
|
||||
@@ -137,23 +149,229 @@ var ListApp = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apps := listAppProject(data)
|
||||
return rt.Output(map[string]any{"count": len(apps), "apps": apps})
|
||||
page, err := listAppParsePage(data, requestCursor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apps, err := listAppProject(page.apps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"count": len(apps),
|
||||
"apps": apps,
|
||||
"hasMore": page.hasMore,
|
||||
"nextCursor": page.nextCursor,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var listAppCollectionKeys = []string{
|
||||
"list", "items", "apps", "appList", "permissions", "events", "versions", "result", "data",
|
||||
}
|
||||
|
||||
type listAppPage struct {
|
||||
apps []any
|
||||
hasMore bool
|
||||
nextCursor string
|
||||
}
|
||||
|
||||
type listAppPageCandidate struct {
|
||||
envelope map[string]any
|
||||
apps []any
|
||||
}
|
||||
|
||||
// listAppParsePage accepts one page at the response top level or under exactly
|
||||
// one existing one-level list envelope. The app list and both pagination facts
|
||||
// must come from that same map; otherwise callers cannot safely distinguish a
|
||||
// terminal page from a truncated or conflicting response.
|
||||
func listAppParsePage(data map[string]any, requestCursor string) (listAppPage, error) {
|
||||
return devAppListParsePage(data, requestCursor, "list_dev_app")
|
||||
}
|
||||
|
||||
func devAppListParsePage(data map[string]any, requestCursor, tool string) (listAppPage, error) {
|
||||
candidates, invalid := listAppPageCandidates(data)
|
||||
if invalid || len(candidates) != 1 {
|
||||
return listAppPage{}, devAppPaginationContractError(tool)
|
||||
}
|
||||
|
||||
candidate := candidates[0]
|
||||
hasMoreValue, ok := candidate.envelope["hasMore"]
|
||||
if !ok {
|
||||
return listAppPage{}, devAppPaginationContractError(tool)
|
||||
}
|
||||
hasMore, ok := hasMoreValue.(bool)
|
||||
if !ok {
|
||||
return listAppPage{}, devAppPaginationContractError(tool)
|
||||
}
|
||||
nextCursorValue, ok := candidate.envelope["nextCursor"]
|
||||
|
||||
if hasMore {
|
||||
if !ok {
|
||||
return listAppPage{}, devAppPaginationContractError(tool)
|
||||
}
|
||||
nextCursor, ok := nextCursorValue.(string)
|
||||
if !ok {
|
||||
return listAppPage{}, devAppPaginationContractError(tool)
|
||||
}
|
||||
if nextCursor == "" || nextCursor == requestCursor {
|
||||
return listAppPage{}, devAppPaginationContractError(tool)
|
||||
}
|
||||
return listAppPage{
|
||||
apps: candidate.apps,
|
||||
hasMore: true,
|
||||
nextCursor: nextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Deployed devapp providers use hasMore as the terminal authority and may
|
||||
// omit nextCursor or return null, an empty string, or the last observed
|
||||
// cursor on a terminal page. Normalize all provider-compatible forms to an
|
||||
// empty public cursor.
|
||||
if ok && nextCursorValue != nil {
|
||||
if _, ok := nextCursorValue.(string); !ok {
|
||||
return listAppPage{}, devAppPaginationContractError(tool)
|
||||
}
|
||||
}
|
||||
|
||||
return listAppPage{
|
||||
apps: candidate.apps,
|
||||
hasMore: false,
|
||||
nextCursor: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// listAppPageCandidates enumerates only the response map and supported maps
|
||||
// directly beneath it. Candidate collection keys that have a wrong type,
|
||||
// multiple list-bearing maps, multiple lists in one map, pagination metadata
|
||||
// without its sibling list, or a second nested envelope all fail closed.
|
||||
func listAppPageCandidates(data map[string]any) ([]listAppPageCandidate, bool) {
|
||||
if data == nil {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
value map[string]any
|
||||
depth int
|
||||
}
|
||||
envelopes := []envelope{{value: data}}
|
||||
for key, value := range data {
|
||||
inner, ok := value.(map[string]any)
|
||||
if ok && listAppIsCollectionKey(key) {
|
||||
envelopes = append(envelopes, envelope{value: inner, depth: 1})
|
||||
}
|
||||
}
|
||||
|
||||
candidates := make([]listAppPageCandidate, 0, 1)
|
||||
for _, current := range envelopes {
|
||||
for key, value := range current.value {
|
||||
if listAppIsCollectionKey(key) {
|
||||
continue
|
||||
}
|
||||
inner, ok := value.(map[string]any)
|
||||
if ok && listAppHasPageEvidence(inner) {
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
|
||||
lists := make([][]any, 0, 1)
|
||||
invalidCollection := false
|
||||
for _, key := range listAppCollectionKeys {
|
||||
value, exists := current.value[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
lists = append(lists, typed)
|
||||
case map[string]any:
|
||||
if current.depth > 0 {
|
||||
invalidCollection = true
|
||||
}
|
||||
default:
|
||||
invalidCollection = true
|
||||
}
|
||||
}
|
||||
|
||||
_, hasHasMore := current.value["hasMore"]
|
||||
_, hasNextCursor := current.value["nextCursor"]
|
||||
hasPageEvidence := len(lists) > 0 || hasHasMore || hasNextCursor || invalidCollection
|
||||
if !hasPageEvidence {
|
||||
continue
|
||||
}
|
||||
if invalidCollection || len(lists) != 1 {
|
||||
return nil, true
|
||||
}
|
||||
candidates = append(candidates, listAppPageCandidate{
|
||||
envelope: current.value,
|
||||
apps: lists[0],
|
||||
})
|
||||
}
|
||||
|
||||
return candidates, false
|
||||
}
|
||||
|
||||
func listAppIsCollectionKey(candidate string) bool {
|
||||
for _, key := range listAppCollectionKeys {
|
||||
if candidate == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func listAppHasPageEvidence(envelope map[string]any) bool {
|
||||
if _, ok := envelope["hasMore"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := envelope["nextCursor"]; ok {
|
||||
return true
|
||||
}
|
||||
for _, key := range listAppCollectionKeys {
|
||||
value, ok := envelope[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
return true
|
||||
case map[string]any:
|
||||
if key == "apps" || key == "appList" || listAppHasPageEvidence(typed) {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
if key == "apps" || key == "appList" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DEC-DWS-917-R1B treats an unprojectable app item as a page-integrity failure:
|
||||
// silently dropping it could turn a real app into a false terminal "not found".
|
||||
// Keep the issue's frozen page-contract reason for both envelope and item failures.
|
||||
func listAppPaginationContractError() error {
|
||||
return devAppPaginationContractError("list_dev_app")
|
||||
}
|
||||
|
||||
func devAppPaginationContractError(tool string) error {
|
||||
return apperrors.NewAPI(
|
||||
tool+" returned an invalid pagination contract",
|
||||
apperrors.WithReason("devapp_pagination_contract_invalid"),
|
||||
)
|
||||
}
|
||||
|
||||
// listAppProject reshapes list_dev_app into a clean app list
|
||||
// ({unifiedAppId, name, appKey, agentId, status, gmtModified}) — output-projection
|
||||
// clean output projection. The list container and per-item field names are probed
|
||||
// defensively across candidate keys, so an unknown/empty shape yields an empty
|
||||
// list rather than a crash or fabricated data.
|
||||
func listAppProject(data map[string]any) []map[string]any {
|
||||
raw := listAppFindList(data)
|
||||
// clean output projection. The page container has already passed the strict
|
||||
// same-envelope pagination contract; per-item field aliases remain compatible.
|
||||
func listAppProject(raw []any) ([]map[string]any, error) {
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, listAppPaginationContractError()
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := listAppFirst(m, "unifiedAppId", "unified_app_id"); ok {
|
||||
@@ -174,36 +392,12 @@ func listAppProject(data map[string]any) []map[string]any {
|
||||
if v, ok := listAppFirst(m, "gmtModified", "gmt_modified", "modifyTime", "modified_time"); ok {
|
||||
row["gmtModified"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
if len(row) == 0 {
|
||||
return nil, listAppPaginationContractError()
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listAppFindList locates the app list payload, tolerating a bare top-level
|
||||
// array or nesting one level under a common envelope key.
|
||||
func listAppFindList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, k := range []string{"list", "items", "apps", "appList", "result", "data"} {
|
||||
v, ok := data[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "apps", "appList", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listAppFirst returns the first present candidate key's value.
|
||||
@@ -665,13 +859,22 @@ var PermissionList = shortcut.Shortcut{
|
||||
if rt.Changed("api-status") {
|
||||
params["apiStatus"] = rt.Str("api-status")
|
||||
}
|
||||
applyCursor(rt, params)
|
||||
requestCursor := applyCursor(rt, params)
|
||||
data, err := rt.CallMCPData(productDevApp, "list_dev_app_permissions", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissions := permissionListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(permissions), "permissions": permissions})
|
||||
page, err := devAppListParsePage(data, requestCursor, "list_dev_app_permissions")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissions := permissionListProject(map[string]any{"items": page.apps})
|
||||
return rt.Output(map[string]any{
|
||||
"count": len(permissions),
|
||||
"permissions": permissions,
|
||||
"hasMore": page.hasMore,
|
||||
"nextCursor": page.nextCursor,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1143,13 +1346,22 @@ var EventList = shortcut.Shortcut{
|
||||
if rt.Changed("keyword") {
|
||||
params["keyword"] = rt.Str("keyword")
|
||||
}
|
||||
applyCursor(rt, params)
|
||||
requestCursor := applyCursor(rt, params)
|
||||
data, err := rt.CallMCPData(productDevApp, "list_dev_app_events", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events := eventListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(events), "events": events})
|
||||
page, err := devAppListParsePage(data, requestCursor, "list_dev_app_events")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events := eventListProject(map[string]any{"events": page.apps})
|
||||
return rt.Output(map[string]any{
|
||||
"count": len(events),
|
||||
"events": events,
|
||||
"hasMore": page.hasMore,
|
||||
"nextCursor": page.nextCursor,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1330,13 +1542,22 @@ var VersionList = shortcut.Shortcut{
|
||||
}, cursorFlags...),
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")}
|
||||
applyCursor(rt, params)
|
||||
requestCursor := applyCursor(rt, params)
|
||||
data, err := rt.CallMCPData(productDevApp, "list_dev_app_versions", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
versions := versionListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(versions), "versions": versions})
|
||||
page, err := devAppListParsePage(data, requestCursor, "list_dev_app_versions")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
versions := versionListProject(map[string]any{"items": page.apps})
|
||||
return rt.Output(map[string]any{
|
||||
"count": len(versions),
|
||||
"versions": versions,
|
||||
"hasMore": page.hasMore,
|
||||
"nextCursor": page.nextCursor,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
// 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 devapp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listPaginationCall struct {
|
||||
product string
|
||||
tool string
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
type listPaginationCaller struct {
|
||||
response string
|
||||
calls []listPaginationCall
|
||||
}
|
||||
|
||||
func (c *listPaginationCaller) CallTool(
|
||||
_ context.Context,
|
||||
product string,
|
||||
tool string,
|
||||
params map[string]any,
|
||||
) (*edition.ToolResult, error) {
|
||||
cloned := make(map[string]any, len(params))
|
||||
for key, value := range params {
|
||||
cloned[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, listPaginationCall{product: product, tool: tool, params: cloned})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.response}}}, nil
|
||||
}
|
||||
|
||||
func (*listPaginationCaller) Format() string { return "json" }
|
||||
func (*listPaginationCaller) DryRun() bool { return false }
|
||||
func (*listPaginationCaller) Fields() string { return "" }
|
||||
func (*listPaginationCaller) JQ() string { return "" }
|
||||
|
||||
func executeListPagination(
|
||||
t *testing.T,
|
||||
response string,
|
||||
args ...string,
|
||||
) (string, error, *listPaginationCaller) {
|
||||
t.Helper()
|
||||
|
||||
caller := &listPaginationCaller{response: response}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().StringP("format", "f", "json", "")
|
||||
root.PersistentFlags().String("fields", "", "")
|
||||
root.PersistentFlags().String("jq", "", "")
|
||||
service := &cobra.Command{Use: "devapp"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(ListApp)))
|
||||
root.AddCommand(service)
|
||||
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetErr(&output)
|
||||
root.SetArgs(append([]string{"devapp", "+list"}, args...))
|
||||
err := root.Execute()
|
||||
return output.String(), err, caller
|
||||
}
|
||||
|
||||
func executeDevAppListShortcut(
|
||||
t *testing.T,
|
||||
command string,
|
||||
definition shortcut.Shortcut,
|
||||
response string,
|
||||
args ...string,
|
||||
) (string, error, *listPaginationCaller) {
|
||||
t.Helper()
|
||||
|
||||
caller := &listPaginationCaller{response: response}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().StringP("format", "f", "json", "")
|
||||
root.PersistentFlags().String("fields", "", "")
|
||||
root.PersistentFlags().String("jq", "", "")
|
||||
service := &cobra.Command{Use: "devapp"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(definition)))
|
||||
root.AddCommand(service)
|
||||
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetErr(&output)
|
||||
root.SetArgs(append([]string{"devapp", command}, args...))
|
||||
err := root.Execute()
|
||||
return output.String(), err, caller
|
||||
}
|
||||
|
||||
func decodeListPaginationEnvelope(t *testing.T, output string) map[string]any {
|
||||
t.Helper()
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(output), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v\noutput=%q", err, output)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func decodePrettyListPagination(t *testing.T, output string) map[string]string {
|
||||
t.Helper()
|
||||
values := map[string]string{}
|
||||
lines := strings.Split(strings.TrimSuffix(output, "\n"), "\n")
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("pretty output lines = %d, want four fields: %q", len(lines), output)
|
||||
}
|
||||
for _, line := range lines {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("pretty output line is not key/value: %q", line)
|
||||
}
|
||||
values[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListPaginationNilRuntimeCursor(t *testing.T) {
|
||||
if got := listAppRequestCursor(nil); got != "" {
|
||||
t.Fatalf("nil runtime cursor = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAllDevAppPaginatedListsPreservePagination(t *testing.T) {
|
||||
const requestCursor = "opaque-current"
|
||||
const nextCursor = "opaque-next"
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
definition shortcut.Shortcut
|
||||
tool string
|
||||
listKey string
|
||||
response string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "permissions",
|
||||
command: "+permission-list",
|
||||
definition: PermissionList,
|
||||
tool: "list_dev_app_permissions",
|
||||
listKey: "permissions",
|
||||
response: `{"result":{"items":[{"scopeValue":"Contact.User.Read"}],"hasMore":true,"nextCursor":"opaque-next"}}`,
|
||||
args: []string{"--unified-app-id", "app-1"},
|
||||
},
|
||||
{
|
||||
name: "events",
|
||||
command: "+event-list",
|
||||
definition: EventList,
|
||||
tool: "list_dev_app_events",
|
||||
listKey: "events",
|
||||
response: `{"result":{"events":[{"eventCode":"chat_update"}],"hasMore":true,"nextCursor":"opaque-next"}}`,
|
||||
args: []string{"--unified-app-id", "app-1"},
|
||||
},
|
||||
{
|
||||
name: "versions",
|
||||
command: "+version-list",
|
||||
definition: VersionList,
|
||||
tool: "list_dev_app_versions",
|
||||
listKey: "versions",
|
||||
response: `{"result":{"items":[{"versionId":"version-1"}],"hasMore":true,"nextCursor":"opaque-next"}}`,
|
||||
args: []string{"--unified-app-id", "app-1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := append([]string{}, test.args...)
|
||||
args = append(args, "--cursor", requestCursor, "--page-size", "1", "--format", "json")
|
||||
output, err, caller := executeDevAppListShortcut(
|
||||
t, test.command, test.definition, test.response, args...,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("execute %s: %v", test.command, err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.product != productDevApp || call.tool != test.tool ||
|
||||
call.params["cursor"] != requestCursor || call.params["pageSize"] != 1 {
|
||||
t.Fatalf("call = %#v", call)
|
||||
}
|
||||
|
||||
payload := decodeListPaginationEnvelope(t, output)
|
||||
items, ok := payload[test.listKey].([]any)
|
||||
if len(payload) != 4 || !ok || len(items) != 1 || payload["count"] != float64(1) ||
|
||||
payload["hasMore"] != true || payload["nextCursor"] != nextCursor {
|
||||
t.Fatalf("pagination payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListPaginationTerminalFormats(t *testing.T) {
|
||||
response := `{
|
||||
"apps":[{
|
||||
"unified_app_id":"app-1",
|
||||
"appName":"Alpha",
|
||||
"clientId":"client-1",
|
||||
"agent_id":42,
|
||||
"appStatus":"ENABLED",
|
||||
"modifyTime":"2026-08-09T00:00:00Z",
|
||||
"ignored":"drop-me"
|
||||
}],
|
||||
"hasMore":false,
|
||||
"nextCursor":""
|
||||
}`
|
||||
|
||||
for _, format := range []string{"json", "raw", "ndjson", "pretty"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
if format == "pretty" {
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
}
|
||||
output, err, caller := executeListPagination(t, response, "--format", format)
|
||||
if err != nil {
|
||||
t.Fatalf("execute terminal page: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one page", caller.calls)
|
||||
}
|
||||
|
||||
if format == "pretty" {
|
||||
values := decodePrettyListPagination(t, output)
|
||||
if len(values) != 4 || values["count"] != "1" || values["hasMore"] != "false" ||
|
||||
values["nextCursor"] != "" || !strings.Contains(values["apps"], `"name":"Alpha"`) {
|
||||
t.Fatalf("pretty pagination values = %#v, output=%q", values, output)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if format == "ndjson" && len(strings.Split(strings.TrimSuffix(output, "\n"), "\n")) != 1 {
|
||||
t.Fatalf("ndjson expanded the page envelope: %q", output)
|
||||
}
|
||||
payload := decodeListPaginationEnvelope(t, output)
|
||||
if len(payload) != 4 || payload["count"] != float64(1) || payload["hasMore"] != false || payload["nextCursor"] != "" {
|
||||
t.Fatalf("terminal pagination payload = %#v", payload)
|
||||
}
|
||||
apps, ok := payload["apps"].([]any)
|
||||
if !ok || len(apps) != 1 {
|
||||
t.Fatalf("apps = %#v", payload["apps"])
|
||||
}
|
||||
app := apps[0].(map[string]any)
|
||||
want := map[string]any{
|
||||
"unifiedAppId": "app-1",
|
||||
"name": "Alpha",
|
||||
"appKey": "client-1",
|
||||
"agentId": float64(42),
|
||||
"status": "ENABLED",
|
||||
"gmtModified": "2026-08-09T00:00:00Z",
|
||||
}
|
||||
if !reflect.DeepEqual(app, want) {
|
||||
t.Fatalf("app projection = %#v, want %#v", app, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListPaginationValidItemProjectionIsOneToOne(t *testing.T) {
|
||||
response := `{
|
||||
"apps":[
|
||||
{"app_name":"Single","unknown":"drop-me"},
|
||||
{"unifiedAppId":"primary-id","unified_app_id":"secondary-id"},
|
||||
{"agent_id":null}
|
||||
],
|
||||
"hasMore":false,
|
||||
"nextCursor":""
|
||||
}`
|
||||
output, err, caller := executeListPagination(t, response, "--format", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("valid item projection rejected: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
payload := decodeListPaginationEnvelope(t, output)
|
||||
apps, ok := payload["apps"].([]any)
|
||||
if len(payload) != 4 || !ok || len(apps) != 3 || payload["count"] != float64(3) {
|
||||
t.Fatalf("one-to-one payload = %#v", payload)
|
||||
}
|
||||
want := []any{
|
||||
map[string]any{"name": "Single"},
|
||||
map[string]any{"unifiedAppId": "primary-id"},
|
||||
map[string]any{"agentId": nil},
|
||||
}
|
||||
if !reflect.DeepEqual(apps, want) {
|
||||
t.Fatalf("projection = %#v, want %#v", apps, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListPaginationSupportedOneLevelEnvelopes(t *testing.T) {
|
||||
for _, outerKey := range listAppCollectionKeys {
|
||||
for _, innerKey := range listAppCollectionKeys {
|
||||
name := outerKey + "/" + innerKey
|
||||
t.Run(name, func(t *testing.T) {
|
||||
response, err := json.Marshal(map[string]any{
|
||||
outerKey: map[string]any{
|
||||
innerKey: []any{map[string]any{"unifiedAppId": "app-envelope"}},
|
||||
"hasMore": false,
|
||||
"nextCursor": "",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, executeErr, caller := executeListPagination(t, string(response), "--format", "json")
|
||||
if executeErr != nil {
|
||||
t.Fatalf("supported envelope rejected: %v, response=%s", executeErr, response)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
payload := decodeListPaginationEnvelope(t, output)
|
||||
if len(payload) != 4 || payload["count"] != float64(1) ||
|
||||
payload["hasMore"] != false || payload["nextCursor"] != "" {
|
||||
t.Fatalf("envelope payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListPaginationRejectsHiddenPageInSupportedEnvelopes(t *testing.T) {
|
||||
for _, outerKey := range listAppCollectionKeys {
|
||||
t.Run(outerKey, func(t *testing.T) {
|
||||
response, err := json.Marshal(map[string]any{
|
||||
outerKey: map[string]any{
|
||||
"apps": []any{},
|
||||
"hasMore": false,
|
||||
"nextCursor": "",
|
||||
"payload": map[string]any{
|
||||
"apps": []any{},
|
||||
"hasMore": false,
|
||||
"nextCursor": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, executeErr, caller := executeListPagination(t, string(response), "--format", "json")
|
||||
if executeErr == nil {
|
||||
t.Fatalf("hidden page unexpectedly accepted: %s", response)
|
||||
}
|
||||
if output != "" || len(caller.calls) != 1 {
|
||||
t.Fatalf("hidden page output=%q calls=%#v", output, caller.calls)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(executeErr, &typed) || typed.Reason != "devapp_pagination_contract_invalid" {
|
||||
t.Fatalf("hidden page error = %T %#v", executeErr, executeErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListPaginationAllowsNonPageMetadataSiblings(t *testing.T) {
|
||||
nonPageMetadata := map[string]any{
|
||||
"data": "trace-data",
|
||||
"result": "trace-result",
|
||||
"list": "trace-list",
|
||||
"items": "trace-items",
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
response map[string]any
|
||||
}{
|
||||
{
|
||||
name: "top-level page",
|
||||
response: map[string]any{
|
||||
"apps": []any{},
|
||||
"hasMore": false,
|
||||
"nextCursor": "",
|
||||
"metadata": nonPageMetadata,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "supported wrapper",
|
||||
response: map[string]any{
|
||||
"result": map[string]any{
|
||||
"apps": []any{},
|
||||
"hasMore": false,
|
||||
"nextCursor": "",
|
||||
"metadata": nonPageMetadata,
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
response, err := json.Marshal(test.response)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, executeErr, caller := executeListPagination(t, string(response), "--format", "json")
|
||||
if executeErr != nil {
|
||||
t.Fatalf("non-page metadata rejected: %v, response=%s", executeErr, response)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
payload := decodeListPaginationEnvelope(t, output)
|
||||
if len(payload) != 4 || payload["count"] != float64(0) ||
|
||||
payload["hasMore"] != false || payload["nextCursor"] != "" {
|
||||
t.Fatalf("non-page metadata payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListPaginationOneLevelAndOpaqueCursor(t *testing.T) {
|
||||
requestCursor := " opaque/request/+==:? "
|
||||
nextCursor := "opaque/next/+==:?"
|
||||
response := fmt.Sprintf(`{
|
||||
"result":{
|
||||
"items":[{"unifiedAppId":"app-2","name":"Beta"}],
|
||||
"hasMore":true,
|
||||
"nextCursor":%q
|
||||
}
|
||||
}`, nextCursor)
|
||||
|
||||
output, err, caller := executeListPagination(t, response,
|
||||
"--cursor", requestCursor,
|
||||
"--page-size", "7",
|
||||
"--name", "Beta",
|
||||
"--app-key", "client-2",
|
||||
"--format", "json",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("execute non-terminal page: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one page without auto-pagination", caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.product != "devapp" || call.tool != "list_dev_app" {
|
||||
t.Fatalf("call target = %s/%s", call.product, call.tool)
|
||||
}
|
||||
wantParams := map[string]any{
|
||||
"cursor": requestCursor,
|
||||
"pageSize": 7,
|
||||
"name": "Beta",
|
||||
"appKey": "client-2",
|
||||
}
|
||||
if !reflect.DeepEqual(call.params, wantParams) {
|
||||
t.Fatalf("params = %#v, want %#v", call.params, wantParams)
|
||||
}
|
||||
payload := decodeListPaginationEnvelope(t, output)
|
||||
if len(payload) != 4 || payload["hasMore"] != true || payload["nextCursor"] != nextCursor || payload["count"] != float64(1) {
|
||||
t.Fatalf("non-terminal payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListPaginationNormalizesDeployedTerminalEnvelope(t *testing.T) {
|
||||
response := `{
|
||||
"arguments":[],
|
||||
"errorCode":null,
|
||||
"errorMsg":null,
|
||||
"result":{
|
||||
"items":[{"unifiedAppId":"app-live","name":"Live App"}],
|
||||
"hasMore":false,
|
||||
"nextCursor":"4010069592"
|
||||
},
|
||||
"success":true
|
||||
}`
|
||||
|
||||
output, err, caller := executeListPagination(t, response, "--format", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("deployed terminal envelope rejected: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].params, map[string]any{"pageSize": 20}) {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
payload := decodeListPaginationEnvelope(t, output)
|
||||
apps, ok := payload["apps"].([]any)
|
||||
if len(payload) != 4 || !ok || len(apps) != 1 || payload["count"] != float64(1) ||
|
||||
payload["hasMore"] != false || payload["nextCursor"] != "" {
|
||||
t.Fatalf("normalized deployed terminal payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListPaginationValidEmptyPages(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
response string
|
||||
args []string
|
||||
wantHasMore bool
|
||||
wantNext string
|
||||
wantRequest map[string]any
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
response: `{"apps":[],"hasMore":false,"nextCursor":""}`,
|
||||
wantHasMore: false,
|
||||
wantNext: "",
|
||||
wantRequest: map[string]any{"pageSize": 20},
|
||||
},
|
||||
{
|
||||
name: "terminal omitted provider cursor",
|
||||
response: `{"apps":[],"hasMore":false}`,
|
||||
wantHasMore: false,
|
||||
wantNext: "",
|
||||
wantRequest: map[string]any{"pageSize": 20},
|
||||
},
|
||||
{
|
||||
name: "terminal null provider cursor",
|
||||
response: `{"apps":[],"hasMore":false,"nextCursor":null}`,
|
||||
wantHasMore: false,
|
||||
wantNext: "",
|
||||
wantRequest: map[string]any{"pageSize": 20},
|
||||
},
|
||||
{
|
||||
name: "terminal last provider cursor",
|
||||
response: `{"apps":[],"hasMore":false,"nextCursor":"4010069592"}`,
|
||||
wantHasMore: false,
|
||||
wantNext: "",
|
||||
wantRequest: map[string]any{"pageSize": 20},
|
||||
},
|
||||
{
|
||||
name: "non-terminal",
|
||||
response: `{"apps":[],"hasMore":true,"nextCursor":"after-empty"}`,
|
||||
args: []string{"--cursor", "before-empty"},
|
||||
wantHasMore: true,
|
||||
wantNext: "after-empty",
|
||||
wantRequest: map[string]any{"cursor": "before-empty", "pageSize": 20},
|
||||
},
|
||||
{
|
||||
name: "explicit empty request cursor",
|
||||
response: `{"apps":[],"hasMore":false,"nextCursor":""}`,
|
||||
args: []string{"--cursor", ""},
|
||||
wantHasMore: false,
|
||||
wantNext: "",
|
||||
wantRequest: map[string]any{"pageSize": 20},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := append(append([]string(nil), test.args...), "--format", "json")
|
||||
output, err, caller := executeListPagination(t, test.response, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("valid empty page rejected: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].params, test.wantRequest) {
|
||||
t.Fatalf("calls = %#v, want params %#v", caller.calls, test.wantRequest)
|
||||
}
|
||||
payload := decodeListPaginationEnvelope(t, output)
|
||||
apps, ok := payload["apps"].([]any)
|
||||
if len(payload) != 4 || !ok || len(apps) != 0 || payload["count"] != float64(0) ||
|
||||
payload["hasMore"] != test.wantHasMore || payload["nextCursor"] != test.wantNext {
|
||||
t.Fatalf("empty page payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListPaginationRejectsInvalidContracts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing hasMore", response: `{"apps":[],"nextCursor":""}`},
|
||||
{name: "wrong hasMore type", response: `{"apps":[],"hasMore":"false","nextCursor":""}`},
|
||||
{name: "non-terminal missing continuation", response: `{"apps":[],"hasMore":true}`},
|
||||
{name: "empty continuation", response: `{"apps":[],"hasMore":true,"nextCursor":""}`},
|
||||
{name: "wrong continuation type", response: `{"apps":[],"hasMore":true,"nextCursor":7}`},
|
||||
{name: "terminal continuation wrong type", response: `{"apps":[],"hasMore":false,"nextCursor":7}`},
|
||||
{name: "stalled cursor", response: `{"apps":[],"hasMore":true,"nextCursor":"same"}`, args: []string{"--cursor", "same"}},
|
||||
{name: "null page", response: `null`},
|
||||
{name: "apps missing", response: `{"hasMore":false,"nextCursor":""}`},
|
||||
{name: "apps wrong type", response: `{"apps":"not-a-list","hasMore":false,"nextCursor":""}`},
|
||||
{name: "null app item", response: `{"apps":[null],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "string app item", response: `{"apps":["bad"],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "number app item", response: `{"apps":[7],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "boolean app item", response: `{"apps":[true],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "nested array app item", response: `{"apps":[[]],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "empty app object", response: `{"apps":[{}],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "unknown-only app object", response: `{"apps":[{"unknown":"value"}],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "mixed valid and invalid app items", response: `{
|
||||
"apps":[{"unifiedAppId":"would-be-partial"},"bad"],
|
||||
"hasMore":false,"nextCursor":""
|
||||
}`},
|
||||
{name: "ambiguous list fields", response: `{"apps":[],"items":[],"hasMore":false,"nextCursor":""}`},
|
||||
{name: "ambiguous page envelopes", response: `{
|
||||
"apps":[],"hasMore":false,"nextCursor":"",
|
||||
"result":{"apps":[],"hasMore":false,"nextCursor":""}
|
||||
}`},
|
||||
{name: "metadata split across envelopes", response: `{
|
||||
"hasMore":false,"nextCursor":"",
|
||||
"result":{"apps":[]}
|
||||
}`},
|
||||
{name: "unknown payload wrapper conflicts with top page", response: `{
|
||||
"apps":[],"hasMore":false,"nextCursor":"",
|
||||
"payload":{"apps":[],"hasMore":false,"nextCursor":""}
|
||||
}`},
|
||||
{name: "unknown response wrapper conflicts with top page", response: `{
|
||||
"apps":[],"hasMore":false,"nextCursor":"",
|
||||
"response":{"items":[],"hasMore":false,"nextCursor":""}
|
||||
}`},
|
||||
{name: "unknown wrapper nextCursor evidence conflicts with top page", response: `{
|
||||
"apps":[],"hasMore":false,"nextCursor":"",
|
||||
"metadata":{"nextCursor":"hidden"}
|
||||
}`},
|
||||
{name: "unknown wrapper recursively nested list conflicts with top page", response: `{
|
||||
"apps":[],"hasMore":false,"nextCursor":"",
|
||||
"metadata":{"data":{"items":[]}}
|
||||
}`},
|
||||
{name: "unknown wrapper invalid apps evidence conflicts with top page", response: `{
|
||||
"apps":[],"hasMore":false,"nextCursor":"",
|
||||
"metadata":{"apps":"invalid"}
|
||||
}`},
|
||||
{name: "unsupported second-level envelope", response: `{
|
||||
"result":{"data":{"apps":[],"hasMore":false,"nextCursor":""}}
|
||||
}`},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := append(append([]string(nil), test.args...), "--format", "json")
|
||||
output, err, caller := executeListPagination(t, test.response, args...)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid response unexpectedly succeeded: %s", test.response)
|
||||
}
|
||||
if output != "" {
|
||||
t.Fatalf("invalid response wrote success output: %q", output)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one bounded read", caller.calls)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T %v, want structured API error", err, err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryAPI || typed.ExitCode() != 1 || typed.Reason != "devapp_pagination_contract_invalid" {
|
||||
t.Fatalf("error contract = %#v", typed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user