368 lines
16 KiB
Go
368 lines
16 KiB
Go
// 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")
|
|
}
|
|
}
|