Compare commits

...
Author SHA1 Message Date
玉澜and修雨 b771969cbe fix(devapp): preserve list pagination contract
Co-authored-by: 修雨 <huyizhou.hyz@alibaba-inc.com>
2026-08-10 18:07:36 +08:00
5 changed files with 706 additions and 37 deletions
+7
View File
@@ -13,6 +13,13 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
targets to the initial card-creation request and prepending its returned
`atTag` to the automatic streaming update.
### Fixed
- **Fail-closed `devapp +list` pagination** (#917) — preserves `hasMore` and
`nextCursor` in every single-page result, normalizes supported terminal
cursor forms, and rejects malformed or ambiguous pagination responses rather
than emitting terminal-looking partial data.
## [1.0.58-beta.2] - 2026-08-10
### Added
+248
View File
@@ -0,0 +1,248 @@
// 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 executeDevAppCommand(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 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 := executeDevAppCommand(t, "+list", "--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)
}
})
}
}
func TestCrossPlatformCoverageDevAppNonTargetShortcutsMockUnchanged(t *testing.T) {
for _, test := range []struct {
name string
command string
want string
}{
{name: "permissions", command: "+permission-list", want: "{\n \"count\": 0,\n \"permissions\": []\n}\n"},
{name: "events", command: "+event-list", want: "{\n \"count\": 0,\n \"events\": []\n}\n"},
{name: "versions", command: "+version-list", want: "{\n \"count\": 0,\n \"versions\": []\n}\n"},
} {
t.Run(test.name, func(t *testing.T) {
stdout, stderr, err := executeDevAppCommand(
t, test.command, "--unified-app-id", "X", "--mock", "--format", "json",
)
if err != nil {
t.Fatalf("mock %s error = %v, stderr=%q", test.command, err, stderr)
}
if stderr != "" || stdout != test.want {
t.Fatalf("mock %s stdout=%q stderr=%q, want stdout=%q", test.command, stdout, stderr, test.want)
}
})
}
}
type devAppListFixtureRunner struct {
response string
invocation *executor.Invocation
}
func (r devAppListFixtureRunner) Run(
_ context.Context,
invocation executor.Invocation,
) (executor.Result, error) {
if r.invocation != nil {
*r.invocation = invocation
}
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": []any{map[string]any{"type": "text", "text": r.response}},
},
}, nil
}
func TestCrossPlatformCoverageDevAppListCursorKeepsCurrentMainTrimSemantics(t *testing.T) {
helpers.InitDepsForTest(t, helpers.GetCaller())
var invocation executor.Invocation
testseam.Swap(t, &rootNewCommandRunnerWithFlags, func(*GlobalFlags) executor.Runner {
return devAppListFixtureRunner{
response: `{"apps":[],"hasMore":true,"nextCursor":"cursor-1"}`,
invocation: &invocation,
}
})
root := NewRootCommand()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"devapp", "+list", "--cursor", " cursor-1 ", "--format", "json"})
err := root.Execute()
if err == nil {
t.Fatal("trimmed stalled cursor unexpectedly succeeded")
}
if invocation.Params["cursor"] != "cursor-1" {
t.Fatalf("forwarded cursor = %#v, want current-main trimmed value", invocation.Params["cursor"])
}
if invocation.Params["pageSize"] != 20 {
t.Fatalf("pageSize = %#v, want 20", invocation.Params["pageSize"])
}
}
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: "list_dev_app_events"},
{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"])
}
})
}
}
+9 -1
View File
@@ -589,13 +589,21 @@ 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" && invocation.Tool == "list_dev_app" {
result = map[string]any{
"apps": []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,
},
+206 -36
View File
@@ -30,6 +30,7 @@ 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"
)
@@ -106,6 +107,10 @@ var ListApp = shortcut.Shortcut{
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
applyCursor(rt, params)
requestCursor := ""
if rt.Changed("cursor") {
requestCursor = rt.Str("cursor")
}
if rt.Changed("name") {
params["name"] = rt.Str("name")
}
@@ -137,23 +142,212 @@ 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", "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 app-list envelope. The app list and pagination facts
// must come from the same map so a partial response cannot look terminal.
func listAppParsePage(data map[string]any, requestCursor string) (listAppPage, error) {
candidates, invalid := listAppPageCandidates(data)
if invalid || len(candidates) != 1 {
return listAppPage{}, listAppPaginationContractError()
}
candidate := candidates[0]
hasMoreValue, ok := candidate.envelope["hasMore"]
if !ok {
return listAppPage{}, listAppPaginationContractError()
}
hasMore, ok := hasMoreValue.(bool)
if !ok {
return listAppPage{}, listAppPaginationContractError()
}
nextCursorValue, hasNextCursor := candidate.envelope["nextCursor"]
if hasMore {
if !hasNextCursor {
return listAppPage{}, listAppPaginationContractError()
}
nextCursor, ok := nextCursorValue.(string)
if !ok || nextCursor == "" || nextCursor == requestCursor {
return listAppPage{}, listAppPaginationContractError()
}
return listAppPage{
apps: candidate.apps,
hasMore: true,
nextCursor: nextCursor,
}, nil
}
// Deployed providers use hasMore as the terminal authority. A terminal
// nextCursor may be absent, null, empty, or the last observed string; all
// supported forms normalize to an empty public cursor.
if hasNextCursor && nextCursorValue != nil {
if _, ok := nextCursorValue.(string); !ok {
return listAppPage{}, listAppPaginationContractError()
}
}
return listAppPage{
apps: candidate.apps,
hasMore: false,
nextCursor: "",
}, nil
}
// listAppPageCandidates enumerates only the response map and supported maps
// directly beneath it. Wrong-typed collection aliases, multiple list-bearing
// maps, split metadata, or a second nested page envelope 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
}
func listAppPaginationContractError() error {
return apperrors.NewAPI(
"list_dev_app 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 +368,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.
@@ -0,0 +1,236 @@
// 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 (
"errors"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestListAppParsePageCollectionAliases(t *testing.T) {
for _, alias := range []string{"list", "items", "apps", "appList", "result", "data"} {
t.Run(alias, func(t *testing.T) {
page, err := listAppParsePage(map[string]any{
alias: []any{map[string]any{"name": alias}},
"hasMore": false,
"nextCursor": nil,
}, "")
if err != nil {
t.Fatalf("parse %s alias: %v", alias, err)
}
if page.hasMore || page.nextCursor != "" || len(page.apps) != 1 {
t.Fatalf("page for %s = %#v", alias, page)
}
})
}
}
func TestListAppParsePageNestedEnvelope(t *testing.T) {
page, err := listAppParsePage(map[string]any{
"result": map[string]any{
"apps": []any{map[string]any{"name": "nested"}},
"hasMore": true,
"nextCursor": "cursor-2",
},
}, "cursor-1")
if err != nil {
t.Fatalf("parse nested page: %v", err)
}
if !page.hasMore || page.nextCursor != "cursor-2" || len(page.apps) != 1 {
t.Fatalf("nested page = %#v", page)
}
}
func TestListAppParsePageTerminalCursorVariants(t *testing.T) {
for _, test := range []struct {
name string
includeNext bool
nextCursor any
}{
{name: "missing"},
{name: "null", includeNext: true, nextCursor: nil},
{name: "empty", includeNext: true, nextCursor: ""},
{name: "last observed", includeNext: true, nextCursor: "cursor-1"},
} {
t.Run(test.name, func(t *testing.T) {
response := map[string]any{
"apps": []any{},
"hasMore": false,
}
if test.includeNext {
response["nextCursor"] = test.nextCursor
}
page, err := listAppParsePage(response, "cursor-1")
if err != nil {
t.Fatalf("parse terminal variant: %v", err)
}
if page.hasMore || page.nextCursor != "" || len(page.apps) != 0 {
t.Fatalf("terminal page = %#v", page)
}
})
}
}
func TestListAppParsePageNonTerminalPreservesCursor(t *testing.T) {
page, err := listAppParsePage(map[string]any{
"apps": []any{},
"hasMore": true,
"nextCursor": " opaque-cursor ",
}, "request-cursor")
if err != nil {
t.Fatalf("parse non-terminal page: %v", err)
}
if !page.hasMore || page.nextCursor != " opaque-cursor " {
t.Fatalf("non-terminal page = %#v", page)
}
}
func TestListAppParsePageRejectsInvalidContracts(t *testing.T) {
tests := []struct {
name string
response map[string]any
requestCursor string
}{
{name: "nil response", response: nil},
{name: "missing list", response: map[string]any{"hasMore": false}},
{name: "missing hasMore", response: map[string]any{"apps": []any{}}},
{name: "hasMore wrong type", response: map[string]any{"apps": []any{}, "hasMore": "false"}},
{name: "non-terminal missing cursor", response: map[string]any{"apps": []any{}, "hasMore": true}},
{name: "non-terminal null cursor", response: map[string]any{"apps": []any{}, "hasMore": true, "nextCursor": nil}},
{name: "non-terminal empty cursor", response: map[string]any{"apps": []any{}, "hasMore": true, "nextCursor": ""}},
{name: "non-terminal stalled cursor", response: map[string]any{"apps": []any{}, "hasMore": true, "nextCursor": "cursor-1"}, requestCursor: "cursor-1"},
{name: "terminal wrong cursor type", response: map[string]any{"apps": []any{}, "hasMore": false, "nextCursor": 1}},
{name: "wrong list type", response: map[string]any{"apps": "bad", "hasMore": false}},
{name: "multiple lists", response: map[string]any{"apps": []any{}, "items": []any{}, "hasMore": false}},
{name: "split metadata", response: map[string]any{
"apps": []any{},
"result": map[string]any{"hasMore": false},
}},
{name: "second nested envelope", response: map[string]any{
"result": map[string]any{
"data": map[string]any{"apps": []any{}, "hasMore": false},
},
}},
{name: "non-list permissions alias", response: map[string]any{"permissions": []any{}, "hasMore": false}},
{name: "non-list events alias", response: map[string]any{"events": []any{}, "hasMore": false}},
{name: "non-list versions alias", response: map[string]any{"versions": []any{}, "hasMore": false}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := listAppParsePage(test.response, test.requestCursor)
assertListAppPaginationError(t, err)
})
}
}
func TestListAppProjectPreservesAliasesOrderAndCount(t *testing.T) {
raw := []any{
map[string]any{
"unified_app_id": "u-1",
"appName": "first",
"clientId": "key-1",
"agent_id": 1,
"appStatus": "ONLINE",
"modifyTime": "t-1",
"unknown": "ignored",
},
map[string]any{
"unifiedAppId": "u-2",
"name": "second",
},
}
got, err := listAppProject(raw)
if err != nil {
t.Fatalf("project valid items: %v", err)
}
want := []map[string]any{
{
"unifiedAppId": "u-1",
"name": "first",
"appKey": "key-1",
"agentId": 1,
"status": "ONLINE",
"gmtModified": "t-1",
},
{"unifiedAppId": "u-2", "name": "second"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("projection = %#v, want %#v", got, want)
}
}
func TestListAppProjectRejectsUnprojectableItems(t *testing.T) {
for _, test := range []struct {
name string
raw []any
}{
{name: "non object", raw: []any{"bad"}},
{name: "empty object", raw: []any{map[string]any{}}},
{name: "unknown only", raw: []any{map[string]any{"unknown": true}}},
{name: "mixed invalid", raw: []any{map[string]any{"name": "valid"}, "bad"}},
} {
t.Run(test.name, func(t *testing.T) {
projected, err := listAppProject(test.raw)
if projected != nil {
t.Fatalf("partial projection leaked = %#v", projected)
}
assertListAppPaginationError(t, err)
})
}
}
func TestNonTargetListAliasesRemainCompatible(t *testing.T) {
permissionItem := map[string]any{"scopeValue": "scope"}
for _, alias := range []string{"permissionList", "scopes"} {
got := permissionListProject(map[string]any{alias: []any{permissionItem}})
if len(got) != 1 || got[0]["scopeValue"] != "scope" {
t.Fatalf("permission alias %s regressed: %#v", alias, got)
}
}
events := eventListProject(map[string]any{
"eventList": []any{map[string]any{"eventCode": "event"}},
})
if len(events) != 1 || events[0]["eventCode"] != "event" {
t.Fatalf("eventList alias regressed: %#v", events)
}
versions := versionListProject(map[string]any{
"versionList": []any{map[string]any{"versionId": "version"}},
})
if len(versions) != 1 || versions[0]["versionId"] != "version" {
t.Fatalf("versionList alias regressed: %#v", versions)
}
}
func assertListAppPaginationError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected pagination contract error")
}
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if structured.Category != apperrors.CategoryAPI ||
structured.Reason != "devapp_pagination_contract_invalid" ||
structured.ExitCode() != 1 {
t.Fatalf("pagination error = %#v", structured)
}
}