test(aitable): close shortcut coverage gaps
This commit is contained in:
1 parent
201949aec1
commit
07ce090eeb
14 files changed
+1529
-25
No files matched your search
@@ -0,0 +1,68 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAITablePageParsingRemainingEdges(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"trailing invalid JSON": `{"records":[]} {`,
|
||||
"data not object": `{"data":[]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := parseRecordQueryPage(raw); err == nil {
|
||||
t.Fatalf("parseRecordQueryPage(%q) succeeded", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableOptionalCountNumericTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value any
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bad number", value: json.Number("1.5"), wantErr: true},
|
||||
{name: "fractional float", value: float64(1.5), wantErr: true},
|
||||
{name: "whole float", value: float64(2), want: 2},
|
||||
{name: "int", value: int(3), want: 3},
|
||||
{name: "int64", value: int64(4), want: 4},
|
||||
{name: "wrong type", value: "5", wantErr: true},
|
||||
{name: "negative", value: int64(-1), wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseOptionalNonNegativeInt(tc.value)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("value %#v succeeded: %#v", tc.value, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got == nil || *got != tc.want {
|
||||
t.Fatalf("value %#v = %#v, %v", tc.value, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableIncompleteResultMetadata(t *testing.T) {
|
||||
total := 9
|
||||
err := recordQueryIncompleteError(paging.Result{
|
||||
Records: []any{map[string]any{"id": "r"}}, Pages: 1, Attempts: 1,
|
||||
HasMore: true, LastCursor: "next", StopReason: paging.StopPageLimit,
|
||||
TotalCount: &total,
|
||||
}, 1)
|
||||
if err == nil || !strings.Contains(err.Error(), "pagination incomplete") {
|
||||
t.Fatalf("incomplete result error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -136,10 +136,9 @@ func executeAttachmentPut(rt *shortcut.RuntimeContext) error {
|
||||
result.Status = "unknown"
|
||||
return compositeError(result, err, false)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(rt.Command().Context(), http.MethodPut, uploadURL, file)
|
||||
if err != nil {
|
||||
return compositeError(result, err, false)
|
||||
}
|
||||
// validateAttachmentUploadURL has already rejected every URL shape for
|
||||
// which NewRequestWithContext can fail; PUT is a fixed valid method.
|
||||
request, _ := http.NewRequestWithContext(rt.Command().Context(), http.MethodPut, uploadURL, file)
|
||||
request.Header.Set("Content-Type", mimeType)
|
||||
request.ContentLength = info.Size()
|
||||
response, err := attachmentHTTPDo(request)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func writeAttachmentFixture(t *testing.T, content string) string {
|
||||
@@ -157,3 +158,275 @@ func TestCrossPlatformCoverageAttachmentRemoveExplainsTokenBoundaryE2E(t *testin
|
||||
t.Fatalf("token boundary = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentPutRemainingOutcomesE2E(t *testing.T) {
|
||||
filePath := writeAttachmentFixture(t, "bytes")
|
||||
|
||||
t.Run("file open failure", func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filepath.Join(t.TempDir(), "missing"), "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("file open = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("initial read failure", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("read failed")}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--yes")
|
||||
if err == nil || out != "" || !strings.Contains(err.Error(), "read failed") {
|
||||
t.Fatalf("initial read = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("append dry run preserves token", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{
|
||||
map[string]any{"file_token": "old-token", "filename": "old.txt"},
|
||||
})}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--dry-run", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "planned"`) || len(caller.calls) != 1 {
|
||||
t.Fatalf("append dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prepare call failure", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
{err: errors.New("prepare failed")},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("prepare failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid upload URL", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
{text: `{"uploadUrl":"http://example.com/upload","fileToken":"ft1"}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid upload URL = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP transport failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &attachmentHTTPDo, func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("transport failed")
|
||||
})
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
{text: `{"uploadUrl":"https://uploads.example.test/put","fileToken":"ft1"}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("transport failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write and read-back both fail", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
|
||||
defer server.Close()
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{})},
|
||||
{text: mustJSONText(t, map[string]any{"uploadUrl": server.URL, "fileToken": "ft1"})},
|
||||
{err: errors.New("write reply failed")},
|
||||
{err: errors.New("verification read failed")},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("verification failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentRemoveRemainingOutcomesE2E(t *testing.T) {
|
||||
run := func(t *testing.T, caller *upsertByKeyCaller, extra ...string) (string, error) {
|
||||
args := []string{"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field"}
|
||||
args = append(args, extra...)
|
||||
args = append(args, "--yes")
|
||||
return runAITableCompositeCLI(t, caller, "+attachment-remove", args...)
|
||||
}
|
||||
|
||||
t.Run("initial read failure", func(t *testing.T) {
|
||||
out, err := run(t, &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("read failed")}}}, "--clear-all")
|
||||
if err == nil || out != "" || !strings.Contains(err.Error(), "read failed") {
|
||||
t.Fatalf("initial read = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unchanged selection", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{
|
||||
map[string]any{"fileToken": "keep", "filename": "keep.pdf"},
|
||||
})}}}
|
||||
out, err := run(t, caller, "--remove-name", "missing.pdf")
|
||||
if err != nil || !strings.Contains(out, `"status": "unchanged"`) || len(caller.calls) != 1 {
|
||||
t.Fatalf("unchanged = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{
|
||||
map[string]any{"fileToken": "remove", "filename": "remove.pdf"},
|
||||
})}}}
|
||||
out, err := run(t, caller, "--remove-name", "remove.pdf", "--dry-run")
|
||||
if err != nil || !strings.Contains(out, `"status": "planned"`) || len(caller.calls) != 1 {
|
||||
t.Fatalf("dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read-back count mismatch", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"fileToken": "remove", "filename": "remove.pdf"}})},
|
||||
{text: `{"updatedCount":1}`},
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "other.pdf"}})},
|
||||
}}
|
||||
out, err := run(t, caller, "--clear-all")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("count mismatch = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("removed name remains", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{
|
||||
map[string]any{"fileToken": "remove", "filename": "remove.pdf"},
|
||||
map[string]any{"fileToken": "keep", "filename": "keep.pdf"},
|
||||
})},
|
||||
{text: `{"updatedCount":1}`},
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "remove.pdf"}})},
|
||||
}}
|
||||
out, err := run(t, caller, "--remove-name", "remove.pdf")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("removed item remains = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write and verification fail", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"fileToken": "remove", "filename": "remove.pdf"}})},
|
||||
{err: errors.New("write reply failed")},
|
||||
{err: errors.New("verification failed")},
|
||||
}}
|
||||
out, err := run(t, caller, "--clear-all")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("verification failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentParsingAndValidation(t *testing.T) {
|
||||
t.Run("file validation and MIME sniffing", func(t *testing.T) {
|
||||
if file, _, _, err := openAttachmentFile(filepath.Join(t.TempDir(), "missing"), ""); err == nil || file != nil {
|
||||
t.Fatalf("missing file must be rejected: file:%v err:%v", file, err)
|
||||
}
|
||||
if file, _, _, err := openAttachmentFile(t.TempDir(), ""); err == nil || file != nil {
|
||||
t.Fatalf("directory must be rejected: file:%v err:%v", file, err)
|
||||
}
|
||||
empty := filepath.Join(t.TempDir(), "empty")
|
||||
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file, _, _, err := openAttachmentFile(empty, ""); err == nil || file != nil {
|
||||
t.Fatalf("empty file must be rejected: file:%v err:%v", file, err)
|
||||
}
|
||||
unknown := filepath.Join(t.TempDir(), "payload.unknown-extension")
|
||||
if err := os.WriteFile(unknown, []byte("plain text"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, _, mimeType, err := openAttachmentFile(unknown, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
if mimeType == "" {
|
||||
t.Fatal("sniffed MIME type is empty")
|
||||
}
|
||||
})
|
||||
|
||||
for name, raw := range map[string]string{
|
||||
"missing host": "https:///upload",
|
||||
"credentials": "https://user:pass@example.com/upload",
|
||||
"wrong scheme": "ftp://example.com/upload",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := validateAttachmentUploadURL(raw); err == nil {
|
||||
t.Fatalf("%q must be rejected", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, raw := range []string{"http://localhost/upload", "http://127.0.0.1/upload", "https://example.com/upload"} {
|
||||
if err := validateAttachmentUploadURL(raw); err != nil {
|
||||
t.Fatalf("%q = %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := verifyAttachmentPut(nil, 1, "token", "name", 1); err == nil {
|
||||
t.Fatal("count mismatch must fail")
|
||||
}
|
||||
if err := verifyAttachmentPut([]map[string]any{{"fileToken": "token"}}, 1, "token", "name", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyAttachmentPut([]map[string]any{{"filename": "other", "size": 1}}, 1, "token", "name", 1); err == nil {
|
||||
t.Fatal("identity mismatch must fail")
|
||||
}
|
||||
for value, want := range map[any]bool{int(1): true, int64(1): true, "1": false} {
|
||||
_, ok := numericInt64(value)
|
||||
if ok != want {
|
||||
t.Fatalf("numericInt64(%T) = %v, want %v", value, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentRemoveSelectionValidationE2E(t *testing.T) {
|
||||
for name, extra := range map[string][]string{
|
||||
"neither": nil,
|
||||
"both": {"--remove-name", "a.pdf", "--clear-all"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
args := []string{"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field"}
|
||||
args = append(args, extra...)
|
||||
args = append(args, "--yes")
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+attachment-remove", args...)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("selection validation = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAttachmentReadBackShapesE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
step upsertByKeyStep
|
||||
want string
|
||||
}{
|
||||
{name: "query error", step: upsertByKeyStep{err: errors.New("query failed")}, want: "query failed"},
|
||||
{name: "wrong record", step: upsertByKeyStep{text: `{"records":[{"recordId":"other","cells":{}}]}`}, want: "exact records"},
|
||||
{name: "missing cells", step: upsertByKeyStep{text: `{"records":[{"recordId":"record"}]}`}, want: "missing cells"},
|
||||
{name: "non-array field", step: upsertByKeyStep{text: `{"records":[{"recordId":"record","cells":{"field":"bad"}}]}`}, want: "not an array"},
|
||||
{name: "non-object item", step: upsertByKeyStep{text: `{"records":[{"recordId":"record","cells":{"field":["bad"]}}]}`}, want: "not an object"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{tc.step}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--clear-all", "--yes")
|
||||
if err == nil || out != "" || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("shape = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[{"recordId":"record","cells":{}}]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
|
||||
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--clear-all", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "unchanged"`) {
|
||||
t.Fatalf("missing field = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
@@ -149,3 +149,135 @@ func TestCrossPlatformCoverageBaseBootstrapUnknownAndDryRunE2E(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseBootstrapInputValidation(t *testing.T) {
|
||||
tooMany := make([]any, 101)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = map[string]any{"name": fmt.Sprintf("T%d", index)}
|
||||
}
|
||||
tooManyJSON, err := json.Marshal(tooMany)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := map[string]string{
|
||||
"invalid JSON": `{`,
|
||||
"not an array": `{}`,
|
||||
"empty array": `[]`,
|
||||
"too many tables": string(tooManyJSON),
|
||||
"non-object table": `[1]`,
|
||||
"empty name": `[{"name":" "}]`,
|
||||
"duplicate name": `[{"name":"T"},{"name":"T"}]`,
|
||||
"fields not array": `[{"name":"T","fields":{}}]`,
|
||||
"field not object": `[{"name":"T","fields":[1]}]`,
|
||||
"field missing type": `[{"name":"T","fields":[{"fieldName":"F"}]}]`,
|
||||
}
|
||||
for name, raw := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if tables, err := parseBootstrapTables(raw); err == nil || tables != nil {
|
||||
t.Fatalf("parseBootstrapTables(%s) = %#v, %v", raw, tables, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseSnapshotFailureStagesE2E(t *testing.T) {
|
||||
base := `{"baseId":"base","tables":[{"tableId":"t1","name":"T"}]}`
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "get base error", steps: []upsertByKeyStep{{err: errors.New("get base failed")}}},
|
||||
{name: "get base wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"other","tables":[]}`}}},
|
||||
{name: "table missing id", steps: []upsertByKeyStep{{text: `{"baseId":"base","tables":[{"name":"T"}]}`}}},
|
||||
{name: "get tables error", steps: []upsertByKeyStep{{text: base}, {err: errors.New("get tables failed")}}},
|
||||
{name: "get tables wrong id", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"other"}]}`}}},
|
||||
{name: "get fields error", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {err: errors.New("get fields failed")}}},
|
||||
{name: "get fields missing collection", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{}`}}},
|
||||
{name: "get views error", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{"fields":[]}`}, {err: errors.New("get views failed")}}},
|
||||
{name: "get views missing collection", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{"fields":[]}`}, {text: `{}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+base-schema-snapshot", "--base-id", "base")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("snapshot failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseBootstrapExecuteRejectsInvalidTablesE2E(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-bootstrap", "--name", "Project", "--tables", `{`)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid bootstrap tables = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseBootstrapFailureStagesE2E(t *testing.T) {
|
||||
tables := marshalBootstrapTables(t, nil)
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []upsertByKeyStep
|
||||
extra []string
|
||||
withField bool
|
||||
}{
|
||||
{name: "missing base id", steps: []upsertByKeyStep{{text: `{}`}}},
|
||||
{name: "base verification error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {err: errors.New("verify base failed")}}},
|
||||
{name: "base verification wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"other"}`}}},
|
||||
{name: "create table error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {err: errors.New("create table failed")}}},
|
||||
{name: "create table missing id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{}`}}},
|
||||
{name: "verify table error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {err: errors.New("verify table failed")}}},
|
||||
{name: "verify table wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[]}`}}},
|
||||
{name: "verify fields error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[{"tableId":"t"}]}`}, {err: errors.New("verify fields failed")}}},
|
||||
{name: "verify fields mismatch", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[{"tableId":"t"}]}`}, {text: `{"fields":[]}`}}, extra: []string{"--folder-id", "folder", "--template-id", "template"}, withField: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
inputTables := tables
|
||||
if tc.withField {
|
||||
inputTables = marshalBootstrapTables(t, bootstrapFields(1))
|
||||
}
|
||||
args := []string{"--name", "Project", "--tables", inputTables}
|
||||
args = append(args, tc.extra...)
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+base-bootstrap", args...)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("bootstrap failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseBootstrapRecoversFieldCallErrorE2E(t *testing.T) {
|
||||
fields := bootstrapFields(16)
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"baseId":"b"}`},
|
||||
{text: `{"baseId":"b"}`},
|
||||
{text: `{"tableId":"t"}`},
|
||||
{err: errors.New("create fields reply failed")},
|
||||
{text: `{"tables":[{"tableId":"t"}]}`},
|
||||
{text: fieldReadBackJSON(t, fields)},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap",
|
||||
"--name", "Project", "--tables", marshalBootstrapTables(t, fields), "--folder-id", "folder", "--template-id", "template")
|
||||
if err != nil || !strings.Contains(out, `"status": "success"`) || !strings.Contains(out, "create_fields offset") {
|
||||
t.Fatalf("field recovery = output:%q err:%v", out, err)
|
||||
}
|
||||
if caller.calls[0].args["folderId"] != "folder" || caller.calls[0].args["templateId"] != "template" {
|
||||
t.Fatalf("create base args = %#v", caller.calls[0].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseCompositeShapeHelpers(t *testing.T) {
|
||||
if _, ok := findNamedObjectList(map[string]any{"tables": "bad"}, "tables"); ok {
|
||||
t.Fatal("non-array named child must fail")
|
||||
}
|
||||
if _, ok := findNamedObjectList(map[string]any{"tables": []any{"bad"}}, "tables"); ok {
|
||||
t.Fatal("non-object list item must fail")
|
||||
}
|
||||
if containsAllFieldNames([]map[string]any{{"fieldName": "A"}}, []any{map[string]any{"fieldName": "B"}}) {
|
||||
t.Fatal("missing field name must fail")
|
||||
}
|
||||
if got := findStringByKeys(map[string]any{"items": []any{map[string]any{"nested": " value "}}}, "nested"); got != "value" {
|
||||
t.Fatalf("nested array string = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -353,10 +353,13 @@ func createdRecordIDs(data map[string]any) []string {
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if createdContext {
|
||||
if id, ok := child.(string); ok && strings.TrimSpace(id) != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
out = append(out, strings.TrimSpace(id))
|
||||
continue
|
||||
if id, ok := child.(string); ok {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(child, createdContext)
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRecordObjectAndIDValidation(t *testing.T) {
|
||||
tooMany := make([]any, maxCompositeRecordRun+1)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = map[string]any{"cells": map[string]any{"f": index}}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
requireID bool
|
||||
}{
|
||||
{name: "invalid JSON", raw: `{`},
|
||||
{name: "not array", raw: `{}`},
|
||||
{name: "empty array", raw: `[]`},
|
||||
{name: "too many", raw: mustJSONText(t, tooMany)},
|
||||
{name: "non-object", raw: `[1]`},
|
||||
{name: "missing cells", raw: `[{"recordId":"r"}]`},
|
||||
{name: "empty cells", raw: `[{"recordId":"r","cells":{}}]`},
|
||||
{name: "missing record id", raw: `[{"cells":{"f":1}}]`, requireID: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if records, err := parseRecordObjects(tc.raw, tc.requireID); err == nil || records != nil {
|
||||
t.Fatalf("parseRecordObjects = %#v, %v", records, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if ids, err := parseRecordIDs([]string{" ", "b", "a", "a"}); err != nil || strings.Join(ids, ",") != "a,b" {
|
||||
t.Fatalf("parseRecordIDs = %#v, %v", ids, err)
|
||||
}
|
||||
tooManyIDs := make([]string, maxCompositeRecordRun+1)
|
||||
for index := range tooManyIDs {
|
||||
tooManyIDs[index] = strconv.Itoa(index)
|
||||
}
|
||||
if ids, err := parseRecordIDs(tooManyIDs); err == nil || ids != nil {
|
||||
t.Fatalf("too many IDs = %#v, %v", ids, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBatchEntryValidationE2E(t *testing.T) {
|
||||
for _, command := range []string{"+record-update", "+record-upsert"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, command,
|
||||
"--base-id", "base", "--table-id", "table", "--records", `{`, "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid records = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+record-delete",
|
||||
"--base-id", "base", "--table-id", "table", "--record-ids", ` `, "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid record IDs = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteDryRunAndDualFailureE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"}, "--dry-run")
|
||||
if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
|
||||
t.Fatalf("delete dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
|
||||
caller = &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("write failed")}, {err: errors.New("verify failed")}}}
|
||||
out, err = runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("delete dual failure = output:%q err:%v", out, err)
|
||||
}
|
||||
|
||||
out, err = runRecordDeleteCLI(t, &upsertByKeyCaller{}, []string{"", " "})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("delete parsed empty IDs = output:%q err:%v", out, err)
|
||||
}
|
||||
|
||||
out, err = runRecordDeleteCLI(t, &upsertByKeyCaller{}, recordIDFixtures(maxCompositeRecordRun+1))
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("delete excessive IDs = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) {
|
||||
record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}}
|
||||
|
||||
t.Run("read-back query error", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"updatedCount":1}`}, {err: errors.New("read failed")}}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-update", record)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("query error = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read-back cells mismatch", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"updatedCount":1}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"f":"old"}}]}`},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-update", record)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("cell mismatch = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upsert create read error", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"createdRecords":[{"recordId":"new"}]}`},
|
||||
{err: errors.New("created read failed")},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-upsert", []map[string]any{{"cells": map[string]any{"f": "new"}}})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("create read error = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upsert create cells mismatch", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"createdRecords":[{"recordId":"new"}]}`},
|
||||
{text: `{"records":[{"recordId":"new","cells":{"f":"old"}}]}`},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-upsert", []map[string]any{{"cells": map[string]any{"f": "new"}}})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("create mismatch = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upsert update read error", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"createdRecords":[],"updatedCount":1}`},
|
||||
{err: errors.New("updated read failed")},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-upsert", record)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("update read error = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("incomplete update read", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"updatedCount":1}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"f":"new"}}],"hasMore":true}`},
|
||||
}}
|
||||
out, err := runRecordBatchCLI(t, caller, "+record-update", record)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("incomplete read = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBatchPureHelpers(t *testing.T) {
|
||||
if err := matchCreatedCells([]map[string]any{{"cells": map[string]any{"f": 1}}}, nil); err == nil {
|
||||
t.Fatal("created result count mismatch must fail")
|
||||
}
|
||||
if err := matchCreatedCells(
|
||||
[]map[string]any{{"cells": map[string]any{"f": 1}}},
|
||||
[]map[string]any{{"recordId": "r", "cells": map[string]any{"f": 2}}},
|
||||
); err == nil {
|
||||
t.Fatal("unmatched created cells must fail")
|
||||
}
|
||||
ids := createdRecordIDs(map[string]any{"createdIds": []any{" r1 ", "r1", map[string]any{"record_id": "r2"}}})
|
||||
if strings.Join(ids, ",") != "r1,r2" {
|
||||
t.Fatalf("created IDs = %#v", ids)
|
||||
}
|
||||
if got := stringSlice("bad"); got != nil {
|
||||
t.Fatalf("stringSlice = %#v", got)
|
||||
}
|
||||
if minInt(1, 2) != 1 || minInt(2, 1) != 1 {
|
||||
t.Fatal("minInt branch mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBulkPatchValidationAndSelectorsE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "invalid patch", args: []string{"--all", "--patch", `{`}},
|
||||
{name: "empty patch", args: []string{"--all", "--patch", `{}`}},
|
||||
{name: "false all", args: []string{"--all=false", "--patch", `{"f":1}`}},
|
||||
{name: "invalid max", args: []string{"--all", "--patch", `{"f":1}`, "--max-matches", "0"}},
|
||||
{name: "invalid filters", args: []string{"--filters", `{`, "--patch", `{"f":1}`}},
|
||||
{name: "empty record IDs", args: []string{"--record-ids", ` `, "--patch", `{"f":1}`}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{"--base-id", "base", "--table-id", "table"}
|
||||
args = append(args, tc.args...)
|
||||
args = append(args, "--yes")
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+record-bulk-patch", args...)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("validation = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
|
||||
"--base-id", "base", "--table-id", "table", "--filters", `{"operator":"and"}`, "--query", "text",
|
||||
"--record-ids", "r2,r1", "--view-id", "view", "--patch", `{"f":1}`, "--yes")
|
||||
if err != nil || !strings.Contains(out, `"matchedCount": 0`) {
|
||||
t.Fatalf("selectors = output:%q err:%v", out, err)
|
||||
}
|
||||
for _, key := range []string{"filters", "keyword", "recordIds", "viewId"} {
|
||||
if _, ok := caller.calls[0].args[key]; !ok {
|
||||
t.Fatalf("query args missing %s: %#v", key, caller.calls[0].args)
|
||||
}
|
||||
}
|
||||
|
||||
caller = &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
|
||||
out, err = runAITableCompositeCLI(t, caller, "+record-bulk-patch",
|
||||
"--base-id", "base", "--table-id", "table", "--query", "none", "--patch", `{"f":1}`, "--dry-run", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "planned"`) {
|
||||
t.Fatalf("empty dry run = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBulkPatchQueryFailuresE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "query error", steps: []upsertByKeyStep{{err: errors.New("query failed")}}},
|
||||
{name: "missing records", steps: []upsertByKeyStep{{text: `{}`}}},
|
||||
{name: "more without cursor", steps: []upsertByKeyStep{{text: `{"records":[],"hasMore":true}`}}},
|
||||
{name: "cursor cycle", steps: []upsertByKeyStep{
|
||||
{text: `{"records":[],"hasMore":true,"nextCursor":"same"}`},
|
||||
{text: `{"records":[],"hasMore":true,"nextCursor":"same"}`},
|
||||
}},
|
||||
{name: "matched record missing id", steps: []upsertByKeyStep{{text: `{"records":[{"cells":{"f":1}}]}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-bulk-patch",
|
||||
"--base-id", "base", "--table-id", "table", "--all", "--patch", `{"f":1}`, "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("query failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryPageSafetyBoundE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(index int, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
return "", fmt.Errorf("unexpected tool %s", tool)
|
||||
}
|
||||
return fmt.Sprintf(`{"records":[],"hasMore":true,"nextCursor":"cursor-%d"}`, index), nil
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
|
||||
"--base-id", "base", "--table-id", "table", "--all", "--patch", `{"f":1}`, "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 10000 {
|
||||
t.Fatalf("page safety = output:%q err:%v calls:%d", out, err, len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertInputAndQueryFailuresE2E(t *testing.T) {
|
||||
baseArgs := []string{"--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--cells", `{"value":1}`, "--yes"}
|
||||
cases := []struct {
|
||||
name string
|
||||
key []string
|
||||
cells string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "empty string key", key: []string{"--key-value", " "}},
|
||||
{name: "invalid JSON key", key: []string{"--key-value-json", `{`}},
|
||||
{name: "null JSON key", key: []string{"--key-value-json", `null`}},
|
||||
{name: "object JSON key", key: []string{"--key-value-json", `{}`}},
|
||||
{name: "invalid cells", key: []string{"--key-value", "v"}, cells: `{`},
|
||||
{name: "conflicting key", key: []string{"--key-value", "v"}, cells: `{"key":"other"}`},
|
||||
{name: "query error", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{err: errors.New("query failed")}}},
|
||||
{name: "missing records", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{}`}}},
|
||||
{name: "incomplete unique query", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{"records":[],"hasMore":true}`}}},
|
||||
{name: "record missing id", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{"records":[{"cells":{"key":"v"}}]}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := append([]string{}, baseArgs...)
|
||||
args = append(args, tc.key...)
|
||||
if tc.cells != "" {
|
||||
for index := range args {
|
||||
if args[index] == "--cells" {
|
||||
args[index+1] = tc.cells
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-upsert-by-key", args...)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("upsert failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordUpsertJSONScalarSuccessE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-upsert-by-key",
|
||||
"--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--key-value-json", `true`,
|
||||
"--cells", `{"value":1}`, "--dry-run", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"keyValue": true`) {
|
||||
t.Fatalf("JSON scalar = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordShapeHelpers(t *testing.T) {
|
||||
if records, ok := findRecords(nil); ok || records != nil {
|
||||
t.Fatalf("nil records = %#v, %v", records, ok)
|
||||
}
|
||||
if _, ok := findRecords(map[string]any{"records": "bad"}); ok {
|
||||
t.Fatal("non-array records must fail")
|
||||
}
|
||||
if _, ok := findRecords(map[string]any{"records": []any{"bad"}}); ok {
|
||||
t.Fatal("non-object record must fail")
|
||||
}
|
||||
if records, ok := findRecords(map[string]any{"result": map[string]any{"records": []any{map[string]any{"id": "r"}}}}); !ok || recordID(records[0]) != "r" {
|
||||
t.Fatalf("nested records = %#v, %v", records, ok)
|
||||
}
|
||||
if responseHasMore(nil) || !responseHasMore(map[string]any{"cursor": "next"}) || !responseHasMore(map[string]any{"pagination": map[string]any{"hasMore": true}}) {
|
||||
t.Fatal("responseHasMore shape mismatch")
|
||||
}
|
||||
if err := verifyRecordCells(map[string]any{"recordId": "r"}, map[string]any{"f": 1}); err == nil {
|
||||
t.Fatal("missing cells must fail")
|
||||
}
|
||||
if responseCursor(nil) != "" || responseCursor(map[string]any{"data": map[string]any{"next_cursor": " next "}}) != "next" {
|
||||
t.Fatal("responseCursor shape mismatch")
|
||||
}
|
||||
}
|
||||
@@ -182,11 +182,8 @@ func executeRecordUpsertByKey(rt *shortcut.RuntimeContext) error {
|
||||
|
||||
func recordKeyValue(rt *shortcut.RuntimeContext) (any, error) {
|
||||
if rt.Changed("key-value") {
|
||||
value := rt.Str("key-value")
|
||||
if value == "" {
|
||||
return nil, apperrors.NewValidation("--key-value 不能为空")
|
||||
}
|
||||
return value, nil
|
||||
// The command framework rejects an explicitly empty string before Execute.
|
||||
return rt.Str("key-value"), nil
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(rt.Str("key-value-json")))
|
||||
decoder.UseNumber()
|
||||
|
||||
@@ -28,6 +28,7 @@ type upsertByKeyCaller struct {
|
||||
steps []upsertByKeyStep
|
||||
calls []aitableURLCall
|
||||
dryRun bool
|
||||
callFn func(index int, product, tool string, args map[string]any) (string, error)
|
||||
}
|
||||
|
||||
func (c *upsertByKeyCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -45,6 +46,13 @@ func (c *upsertByKeyCaller) call(product, tool string, args map[string]any) (*ed
|
||||
}
|
||||
c.calls = append(c.calls, aitableURLCall{product: product, tool: tool, args: cloned})
|
||||
index := len(c.calls) - 1
|
||||
if c.callFn != nil {
|
||||
text, err := c.callFn(index, product, tool, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return urlResult(text), nil
|
||||
}
|
||||
if index >= len(c.steps) {
|
||||
return nil, errors.New("unexpected upsert-by-key call")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAITableProjectionShapeFailures(t *testing.T) {
|
||||
if _, err := resolveNamedList("op", nil, []string{"data"}, []string{"items"}); err == nil {
|
||||
t.Fatal("nil list response must fail")
|
||||
}
|
||||
if _, err := resolveNamedList("op", map[string]any{"data": "bad"}, []string{"data"}, []string{"items"}); err == nil {
|
||||
t.Fatal("non-object envelope must fail")
|
||||
}
|
||||
if _, err := resolveNamedList("op", map[string]any{"data": map[string]any{"other": []any{}}}, []string{"data"}, []string{"items"}); err == nil {
|
||||
t.Fatal("unrecognized envelope must fail")
|
||||
}
|
||||
for name, project := range map[string]func() error{
|
||||
"base": func() error {
|
||||
_, err := baseListProject("list_bases", map[string]any{"bases": []any{"bad"}})
|
||||
return err
|
||||
},
|
||||
"template": func() error {
|
||||
_, err := templateSearchProject(map[string]any{"templates": []any{"bad"}})
|
||||
return err
|
||||
},
|
||||
"view": func() error {
|
||||
_, err := viewGetProject(map[string]any{"views": []any{"bad"}})
|
||||
return err
|
||||
},
|
||||
"form": func() error {
|
||||
_, err := formListProject(map[string]any{"views": []any{"bad"}})
|
||||
return err
|
||||
},
|
||||
"workflow": func() error {
|
||||
_, err := workflowListProject(map[string]any{"workflows": []any{"bad"}})
|
||||
return err
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := project(); err == nil {
|
||||
t.Fatal("non-object projection item must fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableProjectionValidAndMissingIdentityShapes(t *testing.T) {
|
||||
bases, err := baseListProject("list_bases", map[string]any{"bases": []any{map[string]any{"baseId": "b", "baseName": "Base"}}})
|
||||
if err != nil || len(bases) != 1 {
|
||||
t.Fatalf("base projection = %#v, %v", bases, err)
|
||||
}
|
||||
if _, err := templateSearchProject(map[string]any{"templates": []any{map[string]any{"name": "missing"}}}); err == nil {
|
||||
t.Fatal("template missing ID succeeded")
|
||||
}
|
||||
templates, err := templateSearchProject(map[string]any{"templates": []any{map[string]any{"templateId": "t"}}})
|
||||
if err != nil || len(templates) != 1 {
|
||||
t.Fatalf("template projection = %#v, %v", templates, err)
|
||||
}
|
||||
views, err := viewGetProject(map[string]any{"views": []any{map[string]any{"viewId": "v"}}})
|
||||
if err != nil || len(views) != 1 {
|
||||
t.Fatalf("view projection = %#v, %v", views, err)
|
||||
}
|
||||
forms, err := formListProject(map[string]any{"views": []any{map[string]any{"viewId": "f"}}})
|
||||
if err != nil || len(forms) != 1 {
|
||||
t.Fatalf("form projection = %#v, %v", forms, err)
|
||||
}
|
||||
workflows, err := workflowListProject(map[string]any{"workflows": []any{map[string]any{"workflowId": "w"}}})
|
||||
if err != nil || len(workflows) != 1 {
|
||||
t.Fatalf("workflow projection = %#v, %v", workflows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompositeAndReviewedContractFallbacks(t *testing.T) {
|
||||
err := compositeError(newCompositeResult("test"), errors.New("cause"), true)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown") {
|
||||
t.Fatalf("composite default status = %v", err)
|
||||
}
|
||||
unreviewed := shortcut.Shortcut{Command: "+not-reviewed"}
|
||||
if got := withReviewedAITableShortcutContracts(unreviewed); len(got) != 1 || !got[0].Contract.Empty() {
|
||||
t.Fatalf("unreviewed shortcut changed: %#v", got)
|
||||
}
|
||||
value := shortcut.Shortcut{
|
||||
Command: "+base-list",
|
||||
Description: "fallback description",
|
||||
Tips: []string{"one", "two", "three"},
|
||||
}
|
||||
contract := reviewedAITableShortcutContract(value)
|
||||
if contract.Description != "fallback description" || len(contract.Selection.Examples) != 2 {
|
||||
t.Fatalf("reviewed contract fallback = %#v", contract)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageURLResolveAllVerificationKindsE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
tool string
|
||||
text string
|
||||
}{
|
||||
{name: "record", url: "https://alidocs.dingtalk.com/i/nodes/base?iframeQuery=sheetId%3Dtable%26recordId%3Drecord", tool: "query_records", text: `{"records":[{"record_id":"record"}]}`},
|
||||
{name: "view", url: "https://alidocs.dingtalk.com/i/nodes/base?iframeQuery=sheetId%3Dtable%26viewId%3Dview", tool: "get_views", text: `{"views":[{"view_id":"view"}]}`},
|
||||
{name: "base by id", url: "https://alidocs.dingtalk.com/i/nodes/base", tool: "get_base", text: `{"base_id":"base"}`},
|
||||
{name: "base structural fallback", url: "https://alidocs.dingtalk.com/i/nodes/base", tool: "get_base", text: `{"data":{"tables":[]}}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &urlResolveE2ECaller{results: []*edition.ToolResult{urlResult(tc.text)}}
|
||||
out, err := runURLResolveCLI(t, caller, "--url", tc.url, "--verify")
|
||||
if err != nil || !strings.Contains(out, `"verified": true`) || caller.calls[0].tool != tc.tool {
|
||||
t.Fatalf("verification = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("tool error", func(t *testing.T) {
|
||||
caller := &urlResolveE2ECaller{errors: []error{errors.New("verification failed")}}
|
||||
out, err := runURLResolveCLI(t, caller, "--url", "https://alidocs.dingtalk.com/i/nodes/base", "--verify")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("tool error = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("base unknown response", func(t *testing.T) {
|
||||
caller := &urlResolveE2ECaller{results: []*edition.ToolResult{urlResult(`{"success":true}`)}}
|
||||
out, err := runURLResolveCLI(t, caller, "--url", "https://alidocs.dingtalk.com/i/nodes/base", "--verify")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("unknown base = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageURLResolveRecursiveShapeHelpers(t *testing.T) {
|
||||
value := map[string]any{"data": []any{map[string]any{"id": "wanted"}}}
|
||||
if !responseContainsID(value, "wanted", "id") || responseContainsID(value, "other", "id") {
|
||||
t.Fatal("responseContainsID recursion mismatch")
|
||||
}
|
||||
if !responseHasAnyKey(map[string]any{"data": []any{map[string]any{"tables": []any{}}}}, "tables") {
|
||||
t.Fatal("responseHasAnyKey recursion mismatch")
|
||||
}
|
||||
if responseHasAnyKey([]any{"value"}, "tables") {
|
||||
t.Fatal("unrelated key must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetValidationAndDryRunE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "invalid config", config: `{`},
|
||||
{name: "empty config", config: `{}`},
|
||||
{name: "preflight error", config: `{"f":1}`, steps: []upsertByKeyStep{{err: errors.New("views failed")}}},
|
||||
{name: "preflight missing list", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{}`}}},
|
||||
{name: "matched missing id", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{"views":[{"viewName":"X","viewType":"Grid"}]}`}}},
|
||||
{name: "matched type conflict", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v","viewName":"X","viewType":"Kanban"}]}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", tc.config, "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("validation = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"views":[]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--dry-run", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "planned"`) {
|
||||
t.Fatalf("dry run = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetReadBackFailuresAndUpdateE2E(t *testing.T) {
|
||||
preflight := `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":0}}]}`
|
||||
cases := []struct {
|
||||
name string
|
||||
write upsertByKeyStep
|
||||
readBack upsertByKeyStep
|
||||
}{
|
||||
{name: "read error", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{err: errors.New("read failed")}},
|
||||
{name: "missing list", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{}`}},
|
||||
{name: "missing exact name", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{"views":[]}`}},
|
||||
{name: "identity mismatch", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{"views":[{"viewId":"other","viewName":"X","viewType":"Grid","config":{"f":1}}]}`}},
|
||||
{name: "dual failure", write: upsertByKeyStep{err: errors.New("write failed")}, readBack: upsertByKeyStep{err: errors.New("read failed")}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: preflight}, tc.write, tc.readBack}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("read-back failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: preflight}, {text: `{"updated":1}`}, {text: `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":1}}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes")
|
||||
if err != nil || !strings.Contains(out, `"action": "update"`) {
|
||||
t.Fatalf("update success = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetPureMatching(t *testing.T) {
|
||||
if presetViewMatches(map[string]any{"viewType": "Kanban", "config": map[string]any{}}, "Grid", map[string]any{}) {
|
||||
t.Fatal("type mismatch must fail")
|
||||
}
|
||||
if mapContains(map[string]any{}, map[string]any{"missing": 1}) {
|
||||
t.Fatal("missing key must fail")
|
||||
}
|
||||
if mapContains(map[string]any{"nested": 1}, map[string]any{"nested": map[string]any{"x": 1}}) {
|
||||
t.Fatal("nested type mismatch must fail")
|
||||
}
|
||||
if mapContains(map[string]any{"nested": map[string]any{}}, map[string]any{"nested": map[string]any{"x": 1}}) {
|
||||
t.Fatal("nested value mismatch must fail")
|
||||
}
|
||||
if !mapContains(map[string]any{"nested": map[string]any{"x": 1, "extra": true}}, map[string]any{"nested": map[string]any{"x": 1}}) {
|
||||
t.Fatal("nested subset must match")
|
||||
}
|
||||
if mapContains(map[string]any{"x": 1}, map[string]any{"x": 2}) {
|
||||
t.Fatal("scalar mismatch must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowDeployValidationAndFailureStagesE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
dsl string
|
||||
extra []string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "invalid DSL", dsl: `{`},
|
||||
{name: "wrong DSL version", dsl: `{"version":"v0"}`},
|
||||
{name: "update preflight error", dsl: workflowDSLFixture, extra: []string{"--workflow-id", "w"}, steps: []upsertByKeyStep{{err: errors.New("preflight failed")}}},
|
||||
{name: "update target not found", dsl: workflowDSLFixture, extra: []string{"--workflow-id", "w"}, steps: []upsertByKeyStep{{text: `{"flowId":"other"}`}}},
|
||||
{name: "publish missing ID", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true}`}}},
|
||||
{name: "publish ID wrong type", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":1}`}}},
|
||||
{name: "read-back error", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":"w"}`}, {err: errors.New("read failed")}}},
|
||||
{name: "read-back wrong ID", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"other"}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{"--base-id", "base", "--dsl", tc.dsl}
|
||||
args = append(args, tc.extra...)
|
||||
args = append(args, "--yes")
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+workflow-deploy", args...)
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("workflow failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &upsertByKeyCaller{dryRun: true}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--dry-run", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "planned"`) || !strings.Contains(out, "enable and verify") {
|
||||
t.Fatalf("workflow dry run = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowEnableErrorCanStillBeVerifiedE2E(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"w"}`},
|
||||
{err: errors.New("enable reply failed")}, {text: `{"list":[{"flowId":"w","enabled":true}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
|
||||
if err != nil || !strings.Contains(out, "enable response error") {
|
||||
t.Fatalf("enable recovery = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowListFailureShapesE2E(t *testing.T) {
|
||||
for name, step := range map[string]upsertByKeyStep{
|
||||
"list error": {err: errors.New("list failed")},
|
||||
"missing list": {text: `{}`},
|
||||
"not present": {text: `{"list":[]}`},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"w"}`}, {text: `{"enabled":true}`}, step,
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("list failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowListPaginationAndBoundE2E(t *testing.T) {
|
||||
page := make([]any, 100)
|
||||
for index := range page {
|
||||
page[index] = map[string]any{"flowId": fmt.Sprintf("other-%d", index)}
|
||||
}
|
||||
caller := &upsertByKeyCaller{callFn: func(index int, _, tool string, _ map[string]any) (string, error) {
|
||||
switch index {
|
||||
case 0:
|
||||
return `{"valid":true,"flowId":"w"}`, nil
|
||||
case 1:
|
||||
return `{"flowId":"w"}`, nil
|
||||
case 2:
|
||||
return `{"enabled":true}`, nil
|
||||
case 3:
|
||||
return mustJSONText(t, map[string]any{"list": page}), nil
|
||||
default:
|
||||
return `{"list":[{"flowId":"w","status":"ACTIVE"}]}`, nil
|
||||
}
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"running": true`) || len(caller.calls) != 5 {
|
||||
t.Fatalf("paginated list = output:%q err:%v calls:%d", out, err, len(caller.calls))
|
||||
}
|
||||
|
||||
fullPage := mustJSONText(t, map[string]any{"list": page})
|
||||
caller = &upsertByKeyCaller{callFn: func(index int, _, _ string, _ map[string]any) (string, error) {
|
||||
if index == 0 {
|
||||
return `{"valid":true,"flowId":"w"}`, nil
|
||||
}
|
||||
if index == 1 {
|
||||
return `{"flowId":"w"}`, nil
|
||||
}
|
||||
if index == 2 {
|
||||
return `{"enabled":true}`, nil
|
||||
}
|
||||
return fullPage, nil
|
||||
}}
|
||||
out, err = runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 103 {
|
||||
t.Fatalf("workflow list bound = output:%q err:%v calls:%d", out, err, len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowPureResponseHelpers(t *testing.T) {
|
||||
if _, _, _, err := workflowPublishResult(nil); err == nil {
|
||||
t.Fatal("nil publish result must fail")
|
||||
}
|
||||
if value, found := findBoolByKeys(map[string]any{"data": []any{map[string]any{"enabled": true}}}, "enabled"); !found || !value {
|
||||
t.Fatal("nested bool not found")
|
||||
}
|
||||
if _, found := findBoolByKeys([]any{"value"}, "enabled"); found {
|
||||
t.Fatal("unrelated bool unexpectedly found")
|
||||
}
|
||||
for _, workflow := range []map[string]any{
|
||||
{"status": "enabled"}, {"state": "active"}, {"isEnabled": true},
|
||||
} {
|
||||
if !workflowIsRunning(workflow) {
|
||||
t.Fatalf("workflow should be running: %#v", workflow)
|
||||
}
|
||||
}
|
||||
if workflowIsRunning(map[string]any{"status": "STOP", "enabled": false}) {
|
||||
t.Fatal("stopped workflow must not be running")
|
||||
}
|
||||
}
|
||||
@@ -200,9 +200,6 @@ func executeTableCopy(rt *shortcut.RuntimeContext) error {
|
||||
result.Checkpoint = map[string]any{"targetTableId": targetTable, "nextRecordOffset": offset}
|
||||
return compositeError(result, verifyErr, false)
|
||||
}
|
||||
if writeErr != nil {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("create_records offset %d returned an error, but returned IDs and read-back proved the copied state: %v", offset, writeErr))
|
||||
}
|
||||
createdCount = end
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_records", "offset": offset, "recordIds": createdIDs})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func copyFields(count int, prefix string) []any {
|
||||
fields := make([]any, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
fields = append(fields, map[string]any{
|
||||
"fieldId": fmt.Sprintf("%s%d", prefix, index),
|
||||
"fieldName": fmt.Sprintf("F%d", index),
|
||||
"type": "text",
|
||||
})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTableCopyValidationAndDryRunE2E(t *testing.T) {
|
||||
t.Run("invalid maximum", func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+table-copy",
|
||||
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--max-records", "0", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid maximum = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("source read error", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("source fields failed")}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
|
||||
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("source read = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("source fields collection missing", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
|
||||
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("source fields collection = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid source field", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"fields":[{"fieldName":"F","type":"text"}]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
|
||||
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid source field = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run skips unsupported and preserves config", func(t *testing.T) {
|
||||
payload := map[string]any{"fields": []any{
|
||||
map[string]any{"fieldId": "skip", "fieldName": "Formula", "type": "FORMULA"},
|
||||
map[string]any{"fieldId": "keep", "fieldName": "Text", "type": "text", "config": map[string]any{"x": 1}, "aiConfig": map[string]any{"y": 2}},
|
||||
}}
|
||||
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: mustJSONText(t, payload)}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
|
||||
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--dry-run", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "planned"`) || !strings.Contains(out, "skipped field") {
|
||||
t.Fatalf("dry run = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("source records query error", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {err: errors.New("records failed")}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
|
||||
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--include-records", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("source records = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTableCopySchemaFailureStagesE2E(t *testing.T) {
|
||||
source := mustJSONText(t, map[string]any{"fields": copyFields(1, "s")})
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "target fields error", steps: []upsertByKeyStep{{text: source}, {text: `{"tableId":"target"}`}, {err: errors.New("target fields failed")}}},
|
||||
{name: "target fields mismatch", steps: []upsertByKeyStep{{text: source}, {text: `{"tableId":"target"}`}, {text: `{"fields":[]}`}}},
|
||||
{name: "target mapping duplicate", steps: []upsertByKeyStep{
|
||||
{text: source}, {text: `{"tableId":"target"}`},
|
||||
{text: `{"fields":[{"fieldId":"t1","fieldName":"F0","type":"text"},{"fieldId":"t2","fieldName":"F0","type":"text"}]}`},
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+table-copy",
|
||||
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("schema failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTableCopyFieldChunkErrorRecoveredE2E(t *testing.T) {
|
||||
sourceFields := copyFields(16, "s")
|
||||
targetFields := copyFields(16, "t")
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: mustJSONText(t, map[string]any{"fields": sourceFields})},
|
||||
{text: `{"tableId":"target"}`},
|
||||
{err: errors.New("create fields reply failed")},
|
||||
{text: mustJSONText(t, map[string]any{"fields": targetFields})},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
|
||||
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes")
|
||||
if err != nil || !strings.Contains(out, "create_fields offset") || !strings.Contains(out, `"fieldCount": 16`) {
|
||||
t.Fatalf("field chunk recovery = output:%q err:%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTableCopyRecordFailureStagesE2E(t *testing.T) {
|
||||
source := mustJSONText(t, map[string]any{"fields": []any{sourceFieldFixture()}})
|
||||
target := mustJSONText(t, map[string]any{"fields": []any{targetFieldFixture()}})
|
||||
cases := []struct {
|
||||
name string
|
||||
record string
|
||||
after []upsertByKeyStep
|
||||
}{
|
||||
{name: "source record missing cells", record: `{"records":[{"recordId":"source"}]}`},
|
||||
{name: "created IDs read-back error", record: `{"records":[{"recordId":"source","cells":{"sf1":"v"}}]}`, after: []upsertByKeyStep{
|
||||
{text: `{"createdRecords":[{"recordId":"target-record"}]}`}, {err: errors.New("target record read failed")},
|
||||
}},
|
||||
{name: "created records mismatch", record: `{"records":[{"recordId":"source","cells":{"sf1":"v"}}]}`, after: []upsertByKeyStep{
|
||||
{text: `{"createdRecords":[{"recordId":"target-record"}]}`}, {text: `{"records":[{"recordId":"target-record","cells":{"tf1":"wrong"}}]}`},
|
||||
}},
|
||||
{name: "fallback records list", record: `{"records":[{"recordId":"source","cells":{"sf1":"v"}}]}`, after: []upsertByKeyStep{
|
||||
{text: `{"records":[{"recordId":"target-record"}]}`}, {text: `{"records":[{"recordId":"target-record","cells":{"tf1":"v"}}]}`},
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
steps := []upsertByKeyStep{{text: source}, {text: tc.record}, {text: `{"tableId":"target"}`}, {text: target}}
|
||||
steps = append(steps, tc.after...)
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: steps}, "+table-copy",
|
||||
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--include-records", "--yes")
|
||||
if tc.name == "fallback records list" {
|
||||
if err != nil || !strings.Contains(out, `"recordCount": 1`) {
|
||||
t.Fatalf("fallback success = output:%q err:%v", out, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("record failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTableCopyPureHelpers(t *testing.T) {
|
||||
if _, err := mapCopiedFieldIDs(nil, []map[string]any{{"fieldId": "a", "fieldName": "same"}, {"fieldId": "b", "fieldName": "same"}}); err == nil {
|
||||
t.Fatal("duplicate target field must fail")
|
||||
}
|
||||
if _, err := mapCopiedFieldIDs([]map[string]any{{"fieldId": "s", "fieldName": "missing"}}, nil); err == nil {
|
||||
t.Fatal("missing target field must fail")
|
||||
}
|
||||
if normalizedFieldType("FoRmUlA") != "formula" || normalizedFieldType("custom") != "custom" {
|
||||
t.Fatal("field type normalization mismatch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitabletarget
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageParseAITableURLRemainingEdges(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"https://alidocs.dingtalk.com/i/nodes/base%2Fchild",
|
||||
"https://alidocs.dingtalk.com/i/nodes/base?iframeQuery=%25",
|
||||
"https://alidocs.dingtalk.com/i/nodes/base?viewId=one&viewId=two&sheetId=table",
|
||||
"https://alidocs.dingtalk.com/i/nodes/base?recordId=one&recordId=two&sheetId=table",
|
||||
} {
|
||||
if _, err := ParseURL(raw); err == nil {
|
||||
t.Fatalf("ParseURL(%q) succeeded", raw)
|
||||
}
|
||||
}
|
||||
if _, err := parseIframeQuery("%"); err == nil {
|
||||
t.Fatal("invalid iframe escape succeeded")
|
||||
}
|
||||
if values, err := parseIframeQuery("preview"); err != nil || !values.Has("preview") {
|
||||
t.Fatalf("plain iframe query = %#v, %v", values, err)
|
||||
}
|
||||
if _, err := parseIframeQuery("key=%zz"); err == nil {
|
||||
t.Fatal("invalid iframe query value succeeded")
|
||||
}
|
||||
values := url.Values{"id": {" ", "bad/id"}}
|
||||
if _, err := uniqueQueryID(values, "id"); err == nil {
|
||||
t.Fatal("invalid query ID succeeded")
|
||||
}
|
||||
if validID("ok\x7fno") {
|
||||
t.Fatal("control character ID succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveBaseRemainingFailures(t *testing.T) {
|
||||
if _, err := ResolveBaseName(&resolverReader{}, " ", false); err == nil {
|
||||
t.Fatal("empty base name succeeded")
|
||||
}
|
||||
reader := &resolverReader{steps: []resolverStep{{err: errors.New("search failed")}}}
|
||||
if _, err := ResolveBaseName(reader, "name", false); err == nil {
|
||||
t.Fatal("search error was swallowed")
|
||||
}
|
||||
reader = &resolverReader{steps: []resolverStep{
|
||||
{data: map[string]any{"bases": []any{}, "nextCursor": "same"}},
|
||||
{data: map[string]any{"bases": []any{}, "nextCursor": "same"}},
|
||||
}}
|
||||
if _, err := ResolveBaseName(reader, "name", false); errorReason(err) != "target_incomplete" {
|
||||
t.Fatalf("cursor cycle = %v", err)
|
||||
}
|
||||
|
||||
steps := make([]resolverStep, maxResolutionPages)
|
||||
for index := range steps {
|
||||
steps[index] = resolverStep{data: map[string]any{"bases": []any{}, "nextCursor": fmt.Sprintf("cursor-%d", index)}}
|
||||
}
|
||||
reader = &resolverReader{steps: steps}
|
||||
if _, err := ResolveBaseName(reader, "name", false); errorReason(err) != "target_incomplete" || len(reader.calls) != maxResolutionPages {
|
||||
t.Fatalf("page bound = err:%v calls:%d", err, len(reader.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTableRemainingFailures(t *testing.T) {
|
||||
for _, pair := range [][2]string{{"bad/id", "name"}, {"base", " "}} {
|
||||
if _, err := ResolveTableName(&resolverReader{}, pair[0], pair[1], false); err == nil {
|
||||
t.Fatalf("invalid table args %#v succeeded", pair)
|
||||
}
|
||||
}
|
||||
reader := &resolverReader{steps: []resolverStep{{err: errors.New("tables failed")}}}
|
||||
if _, err := ResolveTableName(reader, "base", "name", false); err == nil {
|
||||
t.Fatal("table error was swallowed")
|
||||
}
|
||||
reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"tables": "bad"}}}}
|
||||
if _, err := ResolveTableName(reader, "base", "name", false); errorReason(err) != "target_invalid_response" {
|
||||
t.Fatalf("invalid table list = %v", err)
|
||||
}
|
||||
reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"success": true}}}}
|
||||
if _, err := ResolveTableName(reader, "base", "name", false); errorReason(err) != "target_invalid_response" {
|
||||
t.Fatalf("missing table list = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolverPureShapeHelpers(t *testing.T) {
|
||||
if list, found, err := findObjectList(nil, "items"); err != nil || found || list != nil {
|
||||
t.Fatalf("nil list = %#v, %v, %v", list, found, err)
|
||||
}
|
||||
if _, _, err := findObjectList(map[string]any{"items": "bad"}, "items"); err == nil {
|
||||
t.Fatal("invalid list field succeeded")
|
||||
}
|
||||
if list, found, err := findObjectList(map[string]any{"items": map[string]any{"items": []any{}}}, "items"); err != nil || !found || len(list) != 0 {
|
||||
t.Fatalf("nested keyed list = %#v, %v, %v", list, found, err)
|
||||
}
|
||||
cursor, more, known := pagination(nil)
|
||||
if cursor != "" || more || known {
|
||||
t.Fatalf("nil pagination = %q, %v, %v", cursor, more, known)
|
||||
}
|
||||
cursor, more, known = pagination(map[string]any{"cursor": "outer", "data": map[string]any{"nextCursor": "inner", "hasMore": true}})
|
||||
if cursor != "outer" || !more || !known {
|
||||
t.Fatalf("nested pagination = %q, %v, %v", cursor, more, known)
|
||||
}
|
||||
got := dedupe([]Candidate{{}, {ID: "one", Name: "first"}, {ID: "one", Name: "duplicate"}, {ID: "two", Name: "second"}})
|
||||
if len(got) != 2 || strings.Join([]string{got[0].ID, got[1].ID}, ",") != "one,two" {
|
||||
t.Fatalf("dedupe = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -16,3 +18,17 @@ func TestCrossPlatformCoverageRuntimeContextForTest(t *testing.T) {
|
||||
t.Fatalf("RuntimeContextForTest = %#v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeWriteDataRemainingBranches(t *testing.T) {
|
||||
caller := &runtimeReadCoverageCaller{text: `not-json`}
|
||||
old := helpers.GetCaller()
|
||||
t.Cleanup(func() { helpers.InitDeps(old) })
|
||||
helpers.InitDeps(caller)
|
||||
rt := &RuntimeContext{}
|
||||
if _, err := rt.callMCPWriteData("aitable", "update_records", nil); err == nil || !strings.Contains(err.Error(), "解析") {
|
||||
t.Fatalf("invalid write JSON = %v", err)
|
||||
}
|
||||
if caller.args == nil {
|
||||
t.Fatal("nil write parameters were not normalized")
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func (s *stubFetcher) Fetch(ctx context.Context, cursor string) (Page, error) {
|
||||
return s.pages[idx], nil
|
||||
}
|
||||
|
||||
func TestFetchAll_SinglePage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFetchAllSinglePage(t *testing.T) {
|
||||
s := &stubFetcher{
|
||||
pages: []Page{
|
||||
{Records: []any{"a", "b", "c"}, NextCursor: ""},
|
||||
@@ -44,7 +44,7 @@ func TestFetchAll_SinglePage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAll_MultiPage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFetchAllMultiPage(t *testing.T) {
|
||||
s := &stubFetcher{
|
||||
pages: []Page{
|
||||
{Records: []any{1, 2}, NextCursor: "c1"},
|
||||
@@ -58,7 +58,7 @@ func TestFetchAll_MultiPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAll_PageLimit(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFetchAllPageLimit(t *testing.T) {
|
||||
s := &stubFetcher{
|
||||
pages: []Page{
|
||||
{Records: []any{1}, NextCursor: "c1"},
|
||||
@@ -85,7 +85,7 @@ func TestFetchAll_PageLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAll_MidErrorPartial(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFetchAllMidErrorPartial(t *testing.T) {
|
||||
upstreamErr := errors.New("502 bad gateway")
|
||||
s := &stubFetcher{
|
||||
pages: []Page{
|
||||
@@ -108,7 +108,7 @@ func TestFetchAll_MidErrorPartial(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAll_InitialCursorResume(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFetchAllInitialCursorResume(t *testing.T) {
|
||||
s := &stubFetcher{
|
||||
pages: []Page{
|
||||
{Records: []any{"resumed"}, NextCursor: ""},
|
||||
@@ -123,7 +123,7 @@ func TestFetchAll_InitialCursorResume(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAll_ContextCancel(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFetchAllContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // 立即取消
|
||||
s := &stubFetcher{pages: []Page{{Records: []any{1}, NextCursor: "c1"}}}
|
||||
@@ -136,7 +136,7 @@ func TestFetchAll_ContextCancel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAll_UnlimitedPageLimit(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFetchAllUnlimitedPageLimit(t *testing.T) {
|
||||
// PageLimit=0 是公开契约中的“无限制”。用超过默认安全阀的页数防止
|
||||
// 实现再次偷偷把 0 改写成 DefaultPageLimit。
|
||||
pages := make([]Page, DefaultPageLimit+1)
|
||||
@@ -157,7 +157,7 @@ func TestFetchAll_UnlimitedPageLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAll_PreservesServerTotalCount(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFetchAllPreservesServerTotalCount(t *testing.T) {
|
||||
total := 123
|
||||
s := &stubFetcher{pages: []Page{{Records: []any{1, 2}, TotalCount: &total}}}
|
||||
got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Millisecond})
|
||||
@@ -166,7 +166,7 @@ func TestFetchAll_PreservesServerTotalCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAll_StopsOnCursorCycle(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFetchAllStopsOnCursorCycle(t *testing.T) {
|
||||
s := &stubFetcher{pages: []Page{
|
||||
{Records: []any{1}, NextCursor: "c1"},
|
||||
{Records: []any{2}, NextCursor: "c1"},
|
||||
@@ -179,3 +179,28 @@ func TestFetchAll_StopsOnCursorCycle(t *testing.T) {
|
||||
t.Fatalf("cursor cycle progress: got=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFetchAllCancellationDuringDelay(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
got := FetchAll(ctx, func(context.Context, string) (Page, error) {
|
||||
calls++
|
||||
time.AfterFunc(10*time.Millisecond, cancel)
|
||||
return Page{Records: []any{"first"}, NextCursor: "next"}, nil
|
||||
}, Options{InterPageDelay: time.Second})
|
||||
if calls != 1 || got.StopReason != StopCanceled || !errors.Is(got.Err, context.Canceled) || got.LastCursor != "next" {
|
||||
t.Fatalf("delay cancellation = %+v calls:%d", got, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFetchAllStopsOnPriorCursorCycle(t *testing.T) {
|
||||
s := &stubFetcher{pages: []Page{
|
||||
{Records: []any{1}, NextCursor: "c1"},
|
||||
{Records: []any{2}, NextCursor: "c2"},
|
||||
{Records: []any{3}, NextCursor: "c1"},
|
||||
}}
|
||||
got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Millisecond})
|
||||
if got.StopReason != StopCursorCycle || got.Pages != 3 || got.LastCursor != "c1" || !errors.Is(got.Err, ErrCursorCycle) {
|
||||
t.Fatalf("prior cursor cycle = %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user