diff --git a/internal/helpers/aitable_coverage_gap_test.go b/internal/helpers/aitable_coverage_gap_test.go new file mode 100644 index 00000000..009e60c3 --- /dev/null +++ b/internal/helpers/aitable_coverage_gap_test.go @@ -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) + } +} diff --git a/internal/shortcut/aitable/attachment_composite.go b/internal/shortcut/aitable/attachment_composite.go index 219e907d..3060f7f8 100644 --- a/internal/shortcut/aitable/attachment_composite.go +++ b/internal/shortcut/aitable/attachment_composite.go @@ -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) diff --git a/internal/shortcut/aitable/attachment_composite_e2e_test.go b/internal/shortcut/aitable/attachment_composite_e2e_test.go index 24163c6e..37b257d5 100644 --- a/internal/shortcut/aitable/attachment_composite_e2e_test.go +++ b/internal/shortcut/aitable/attachment_composite_e2e_test.go @@ -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) + } +} diff --git a/internal/shortcut/aitable/base_composite_e2e_test.go b/internal/shortcut/aitable/base_composite_e2e_test.go index 7ef86924..dbf7515a 100644 --- a/internal/shortcut/aitable/base_composite_e2e_test.go +++ b/internal/shortcut/aitable/base_composite_e2e_test.go @@ -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) + } +} diff --git a/internal/shortcut/aitable/record_batch.go b/internal/shortcut/aitable/record_batch.go index 6ad8faca..9ad7f9ad 100644 --- a/internal/shortcut/aitable/record_batch.go +++ b/internal/shortcut/aitable/record_batch.go @@ -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) diff --git a/internal/shortcut/aitable/record_coverage_gap_test.go b/internal/shortcut/aitable/record_coverage_gap_test.go new file mode 100644 index 00000000..df470d64 --- /dev/null +++ b/internal/shortcut/aitable/record_coverage_gap_test.go @@ -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") + } +} diff --git a/internal/shortcut/aitable/record_upsert_by_key.go b/internal/shortcut/aitable/record_upsert_by_key.go index fe44d7eb..038afa45 100644 --- a/internal/shortcut/aitable/record_upsert_by_key.go +++ b/internal/shortcut/aitable/record_upsert_by_key.go @@ -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() diff --git a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go index 1738f2d8..64bf759a 100644 --- a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go +++ b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go @@ -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") } diff --git a/internal/shortcut/aitable/remaining_coverage_gap_test.go b/internal/shortcut/aitable/remaining_coverage_gap_test.go new file mode 100644 index 00000000..2b834f68 --- /dev/null +++ b/internal/shortcut/aitable/remaining_coverage_gap_test.go @@ -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") + } +} diff --git a/internal/shortcut/aitable/table_copy.go b/internal/shortcut/aitable/table_copy.go index 03cfd5fe..6da8c639 100644 --- a/internal/shortcut/aitable/table_copy.go +++ b/internal/shortcut/aitable/table_copy.go @@ -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}) } diff --git a/internal/shortcut/aitable/table_copy_coverage_gap_test.go b/internal/shortcut/aitable/table_copy_coverage_gap_test.go new file mode 100644 index 00000000..c051befc --- /dev/null +++ b/internal/shortcut/aitable/table_copy_coverage_gap_test.go @@ -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") + } +} diff --git a/internal/shortcut/aitabletarget/resolver_coverage_gap_test.go b/internal/shortcut/aitabletarget/resolver_coverage_gap_test.go new file mode 100644 index 00000000..9a66cddd --- /dev/null +++ b/internal/shortcut/aitabletarget/resolver_coverage_gap_test.go @@ -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) + } +} diff --git a/internal/shortcut/runner_coverage_test.go b/internal/shortcut/runner_coverage_test.go index daa72b3e..51f90346 100644 --- a/internal/shortcut/runner_coverage_test.go +++ b/internal/shortcut/runner_coverage_test.go @@ -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") + } +} diff --git a/pkg/paging/paging_test.go b/pkg/paging/paging_test.go index 2a7e5080..c507a9b7 100644 --- a/pkg/paging/paging_test.go +++ b/pkg/paging/paging_test.go @@ -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) + } +}