Files
dingtalk-workspace-cli/internal/helpers/aitable_command_edges_test.go

300 lines
19 KiB
Go

package helpers
import (
"context"
"fmt"
"io"
"os"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type aitableCommandCoverageCaller struct {
viewType string
err error
response map[string]string
}
func (c *aitableCommandCoverageCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) {
if c.err != nil {
return nil, c.err
}
if response, ok := c.response[tool]; ok {
return textToolResult(response), nil
}
viewType := c.viewType
if viewType == "" {
viewType = "Grid"
}
var response string
switch tool {
case "get_views":
response = fmt.Sprintf(`{"data":{"views":[{"viewId":"view","viewType":%q,"kanbanCard":{},"galleryCard":{},"ganttTimebar":{},"aggregate":{},"filter":[],"sort":[],"group":[],"visibleFieldIds":[],"custom":{"widthMap":{}}}]}}`, viewType)
case "list_form_views":
response = `{"data":[{"viewId":"view","title":"Form"}]}`
case "query_records":
response = `{"data":{"records":[],"hasMore":false,"nextCursor":""}}`
default:
response = `{"success":true,"data":{}}`
}
return textToolResult(response), nil
}
func (*aitableCommandCoverageCaller) Format() string { return "json" }
func (*aitableCommandCoverageCaller) DryRun() bool { return false }
func (*aitableCommandCoverageCaller) Fields() string { return "" }
func (*aitableCommandCoverageCaller) JQ() string { return "" }
func runAitableCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
t.Helper()
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newAitableCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.ExecuteContext(context.Background())
}
func TestCrossPlatformCoverageAitableRetryWrappersExhaustAndRecover(t *testing.T) {
oldDeps, oldSleep := deps, helperSleep
t.Cleanup(func() { deps, helperSleep = oldDeps, oldSleep })
helperSleep = func(time.Duration) {}
retryable := fmt.Errorf("timeout: retryable: true")
caller := &aitableTestCaller{errors: []error{retryable, retryable, retryable, retryable}}
installAitableDeps(t, caller)
if err := callAitableTool("retry", nil); err == nil {
t.Fatal("exhausted aitable retries returned nil")
}
caller = &aitableTestCaller{errors: []error{retryable, retryable}}
installAitableDeps(t, caller)
if err := callAitableHelperTool("retry", nil); err != nil {
t.Fatalf("helper retry did not recover: %v", err)
}
caller = &aitableTestCaller{errors: []error{retryable, retryable, retryable, retryable}}
installAitableDeps(t, caller)
if err := callAitableHelperTool("retry", nil); err == nil {
t.Fatal("exhausted helper retries returned nil")
}
}
func TestCrossPlatformCoverageAitableCommandValidationEdges(t *testing.T) {
oldDeps, oldArgs, oldStdin, oldSleep := deps, os.Args, os.Stdin, helperSleep
t.Cleanup(func() {
deps, os.Args, os.Stdin, helperSleep = oldDeps, oldArgs, oldStdin, oldSleep
})
helperSleep = func(time.Duration) {}
os.Args = []string{"dws", "aitable", "--yes"}
caller := &aitableCommandCoverageCaller{}
manyIDs := make([]string, 101)
for i := range manyIDs {
manyIDs[i] = fmt.Sprintf("r%d", i)
}
manyRecords := "[" + strings.TrimSuffix(strings.Repeat(`{"cells":{}},`, 101), ",") + "]"
scenarios := []struct {
name string
args []string
}{
{"primary doc missing base", []string{"base", "get-primary-doc-id", "--table-id=t", "--record-id=r"}},
{"table create invalid fields", []string{"table", "create", "--base-id=b", "--name=n", "--fields={"}},
{"table create missing base", []string{"table", "create", "--name=n", `--fields=[{"fieldName":"N","type":"text"}]`}},
{"field create invalid fields", []string{"field", "create", "--base-id=b", "--table-id=t", "--fields={"}},
{"field create invalid configured options", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=n", "--type=singleSelect", "--config={}", "--options={"}},
{"field create configured options", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=n", "--type=singleSelect", "--config={}", `--options=[{"name":"A"}]`}},
{"field create scalar config", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=n", "--type=text", "--config=[]"}},
{"field create invalid options", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=n", "--type=singleSelect", "--options={"}},
{"field create options and ai", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=n", "--type=singleSelect", `--options=[{"name":"A"}]`, `--ai-config={"enabled":true}`}},
{"field update no changes", []string{"field", "update", "--base-id=b", "--table-id=t", "--field-id=f"}},
{"query explicitly empty table", []string{"record", "query", "--base-id=b", "--table-id="}},
{"query rich options", []string{"record", "query", "--base-id=b", "--table-id=t", "--view-id=v", "--record-ids=r1,r2", "--field-ids=f1,f2", `--filters={"operator":"and","operands":[{"operator":"eq","operands":["f","v"]}]}`, `--sort=[{"fieldId":"f","order":"desc"},{"fieldId":"g","order":"asc","direction":"desc"}]`, "--keyword=k", "--page-size=2", "--cursor=c"}},
{"query primary options", []string{"record", "query", "--base-id=b", "--table-id=t", "--query=k", "--limit=2"}},
{"query invalid sort", []string{"record", "query", "--base-id=b", "--table-id=t", "--sort={"}},
{"query all default page limit", []string{"record", "query", "--base-id=b", "--table-id=t", "--all"}},
{"query all unlimited", []string{"record", "query", "--base-id=b", "--table-id=t", "--all", "--page-limit=0"}},
{"create invalid cells", []string{"record", "create", "--base-id=b", "--table-id=t", "--cells={"}},
{"create cells shortcut", []string{"record", "create", "--base-id=b", "--table-id=t", `--cells={"f":"v"}`}},
{"create invalid records", []string{"record", "create", "--base-id=b", "--table-id=t", "--records={"}},
{"update half shortcut", []string{"record", "update", "--base-id=b", "--table-id=t", "--record-id=r"}},
{"update invalid shortcut cells", []string{"record", "update", "--base-id=b", "--table-id=t", "--record-id=r", "--cells={"}},
{"update shortcut missing base", []string{"record", "update", "--table-id=t", "--record-id=r", `--cells={"f":"v"}`}},
{"update invalid records", []string{"record", "update", "--base-id=b", "--table-id=t", "--records={"}},
{"update records", []string{"record", "update", "--base-id=b", "--table-id=t", `--records=[{"recordId":"r","cells":{}}]`}},
{"batch empty ids", []string{"record", "batch-update", "--base-id=b", "--table-id=t", "--record-ids=,", `--cells={"f":"v"}`}},
{"batch too many ids", []string{"record", "batch-update", "--base-id=b", "--table-id=t", "--record-ids=" + strings.Join(manyIDs, ","), `--cells={"f":"v"}`}},
{"batch invalid cells", []string{"record", "batch-update", "--base-id=b", "--table-id=t", "--record-ids=r", "--cells={"}},
{"batch empty cells", []string{"record", "batch-update", "--base-id=b", "--table-id=t", "--record-ids=r", "--cells={}"}},
{"batch missing base", []string{"record", "batch-update", "--table-id=t", "--record-ids=r", `--cells={"f":"v"}`}},
{"query empty invalid limit", []string{"record", "query-empty", "--base-id=b", "--table-id=t", "--limit=0"}},
{"history invalid offset", []string{"record", "history-list", "--base-id=b", "--table-id=t", "--record-id=r", "--offset=-1"}},
{"history invalid limit", []string{"record", "history-list", "--base-id=b", "--table-id=t", "--record-id=r", "--limit=0"}},
{"share empty ids", []string{"record", "share-url", "--base-id=b", "--table-id=t", "--record-ids=,"}},
{"share too many ids", []string{"record", "share-url", "--base-id=b", "--table-id=t", "--record-ids=" + strings.Join(manyIDs[:21], ",")}},
{"upsert invalid records", []string{"record", "upsert", "--base-id=b", "--table-id=t", "--records={"}},
{"upsert empty records", []string{"record", "upsert", "--base-id=b", "--table-id=t", "--records=[]"}},
{"upsert too many records", []string{"record", "upsert", "--base-id=b", "--table-id=t", "--records=" + manyRecords}},
{"primary doc create missing base", []string{"record", "primary-doc-create", "--table-id=t", "--field-id=f", "--record-id=r"}},
{"attachment all options", []string{"attachment", "upload", "--base-id=b", "--file-name=x", "--size=1", "--mime-type=text/plain"}},
{"form update no changes", []string{"form", "update", "--base-id=b", "--table-id=t", "--view-id=v"}},
{"form field update missing base", []string{"form", "field", "update", "--table-id=t", "--view-id=v", "--field-id=f"}},
{"form field hide missing base", []string{"form", "field", "hide", "--table-id=t", "--view-id=v", "--field-id=f", "--hidden=true"}},
{"form share update missing base", []string{"form", "share", "update", "--table-id=t", "--view-id=v", "--enabled=true"}},
{"dashboard create empty", []string{"dashboard", "create", "--base-id=b"}},
{"dashboard create invalid config scalar", []string{"dashboard", "create", "--base-id=b", "--config=[]"}},
{"dashboard create config", []string{"dashboard", "create", "--base-id=b", `--config={"name":"n"}`}},
{"dashboard update config", []string{"dashboard", "update", "--base-id=b", "--dashboard-id=d", `--config={"name":"n"}`}},
{"dashboard share invalid bool", []string{"dashboard", "share", "update", "--base-id=b", "--dashboard-id=d", "--enabled=invalid"}},
{"dashboard share missing base", []string{"dashboard", "share", "update", "--dashboard-id=d", "--enabled=true"}},
{"dashboard share all options", []string{"dashboard", "share", "update", "--base-id=b", "--dashboard-id=d", "--enabled=true", "--share-type=PUBLIC", "--allow-back-to-doc"}},
{"chart create missing base", []string{"chart", "create", "--dashboard-id=d", `--config={"name":"n"}`, `--layout={"x":0}`}},
{"chart update missing base", []string{"chart", "update", "--dashboard-id=d", "--chart-id=c", `--config={"name":"n"}`}},
{"chart share invalid bool", []string{"chart", "share", "update", "--base-id=b", "--dashboard-id=d", "--chart-id=c", "--enabled=invalid"}},
{"chart share missing base", []string{"chart", "share", "update", "--dashboard-id=d", "--chart-id=c", "--enabled=true"}},
{"chart share all options", []string{"chart", "share", "update", "--base-id=b", "--dashboard-id=d", "--chart-id=c", "--enabled=true", "--share-type=ORG", "--allow-back-to-doc"}},
{"workflow invalid limit", []string{"workflow", "list", "--base-id=b", "--limit=0"}},
{"workflow invalid offset", []string{"workflow", "list", "--base-id=b", "--offset=-1"}},
{"export create task", []string{"export", "data", "--base-id=b", "--scope=all", "--format=excel", "--table-id=t", "--view-id=v", "--timeout-ms=1"}},
{"role create invalid json", []string{"advperm", "role-create", "--base-id=b", "--name=n", "--sub-roles={"}},
{"role create non-array", []string{"advperm", "role-create", "--base-id=b", "--name=n", "--sub-roles={}"}},
{"role create array", []string{"advperm", "role-create", "--base-id=b", "--name=n", "--sub-roles=[]"}},
{"role update invalid json", []string{"advperm", "role-update", "--base-id=b", "--role-id=r", "--sub-roles={"}},
{"role update non-array", []string{"advperm", "role-update", "--base-id=b", "--role-id=r", "--sub-roles={}"}},
{"role update array", []string{"advperm", "role-update", "--base-id=b", "--role-id=r", "--sub-roles=[]"}},
{"import rich options", []string{"import", "data", "--import-id=i", "--table-id=t", "--timeout=1", "--header-row=2", "--src-sheet-name=Sheet1", `--field-mapping={"A":"f"}`}},
{"import invalid mapping", []string{"import", "data", "--import-id=i", "--field-mapping={"}},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if err := runAitableCoverageCommand(t, caller, scenario.args...); err != nil {
t.Logf("command returned: %v", err)
}
})
}
formGet := []string{"form", "get", "--base-id=b", "--table-id=t", "--view-id=view"}
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{err: fmt.Errorf("transport")}, formGet...)
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{response: map[string]string{"list_form_views": "{"}}, formGet...)
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{response: map[string]string{"list_form_views": `{}`}}, formGet...)
_ = runAitableCoverageCommand(t, caller, formGet...)
}
func TestCrossPlatformCoverageAitableViewCommandEdges(t *testing.T) {
oldDeps, oldArgs, oldSleep := deps, os.Args, helperSleep
t.Cleanup(func() { deps, os.Args, helperSleep = oldDeps, oldArgs, oldSleep })
helperSleep = func(time.Duration) {}
os.Args = []string{"dws", "aitable", "--yes"}
type scenario struct {
name string
viewType string
args []string
}
base := []string{"--base-id=b", "--table-id=t", "--view-id=view"}
with := func(prefix []string, flags ...string) []string {
out := append([]string(nil), prefix...)
out = append(out, base...)
return append(out, flags...)
}
scenarios := []scenario{
{"get card unsupported", "Grid", with([]string{"view", "get", "card"})},
{"get card", "Kanban", with([]string{"view", "get", "card"})},
{"get timebar wrong type", "Grid", with([]string{"view", "get", "timebar"})},
{"get timebar", "Gantt", with([]string{"view", "get", "timebar"})},
{"get filter", "Grid", with([]string{"view", "get", "filter"})},
{"update invalid config", "Grid", with([]string{"view", "update"}, `--config={"filter":1}`)},
{"update normalized config", "Grid", with([]string{"view", "update"}, `--config={"filter":{"operator":"and","operands":[]}}`)},
{"card conflict", "Kanban", with([]string{"view", "update", "card"}, "--no-cover", "--cover-field-id=f")},
{"card unsupported", "Grid", with([]string{"view", "update", "card"}, "--no-cover")},
{"card no cover", "Kanban", with([]string{"view", "update", "card"}, "--no-cover", "--hidden-field-title", "--display-field-name")},
{"card cover", "Gallery", with([]string{"view", "update", "card"}, "--cover-field-id=f", "--cover-mode=auto")},
{"card invalid json", "Kanban", with([]string{"view", "update", "card"}, "--json=[]")},
{"timebar wrong type", "Grid", with([]string{"view", "update", "timebar"}, "--start-field=f")},
{"timebar invalid colors", "Gantt", with([]string{"view", "update", "timebar"}, "--color-configs={}")},
{"timebar colors", "Gantt", with([]string{"view", "update", "timebar"}, "--color-configs=[]", "--official-holiday")},
{"timebar invalid json", "Gantt", with([]string{"view", "update", "timebar"}, "--json=[]")},
{"aggregate half pair", "Grid", with([]string{"view", "update", "aggregate"}, "--field-id=f")},
{"aggregate typed clear", "Grid", with([]string{"view", "update", "aggregate"}, "--field-id=f", "--action=SUM", "--clear-field-id=x,y")},
{"aggregate invalid json", "Grid", with([]string{"view", "update", "aggregate"}, "--json=[]")},
{"width half pair", "Grid", with([]string{"view", "update", "field-widths"}, "--field-id=f")},
{"width typed", "Grid", with([]string{"view", "update", "field-widths"}, "--field-id=f", "--width=120")},
{"width invalid json", "Grid", with([]string{"view", "update", "field-widths"}, "--json=[]")},
{"visible non-array", "Grid", with([]string{"view", "update", "visible-fields"}, "--json={}")},
{"visible mixed array", "Grid", with([]string{"view", "update", "visible-fields"}, `--json=["f",1]`)},
{"visible empty", "Grid", with([]string{"view", "update", "visible-fields"})},
{"visible both", "Grid", with([]string{"view", "update", "visible-fields"}, "--field-ids=x", `--json=["f","g"]`)},
{"filter missing json", "Grid", with([]string{"view", "update", "filter"})},
{"filter invalid json", "Grid", with([]string{"view", "update", "filter"}, "--json={")},
{"filter invalid shape", "Grid", with([]string{"view", "update", "filter"}, "--json=1")},
{"filter valid object", "Grid", with([]string{"view", "update", "filter"}, `--json={"operator":"and","operands":[]}`)},
{"sort valid", "Grid", with([]string{"view", "update", "sort"}, `--json=[{"fieldId":"f","direction":"asc"}]`)},
{"group valid", "Grid", with([]string{"view", "update", "group"}, `--json=[{"fieldId":"f","direction":"asc"}]`)},
{"name missing base", "Grid", []string{"view", "update", "name", "--table-id=t", "--view-id=view", "--name=n"}},
{"frozen negative", "Grid", with([]string{"view", "update", "frozen-cols"}, "--count=-1")},
{"frozen missing base", "Grid", []string{"view", "update", "frozen-cols", "--table-id=t", "--view-id=view", "--count=1"}},
{"row height invalid", "Grid", with([]string{"view", "update", "row-height"}, "--cell-height=0")},
{"row height missing base", "Grid", []string{"view", "update", "row-height", "--table-id=t", "--view-id=view", "--cell-height=32"}},
{"fill missing json", "Grid", with([]string{"view", "update", "fill-color-rule"})},
{"fill invalid json", "Grid", with([]string{"view", "update", "fill-color-rule"}, "--json={")},
{"fill non-array", "Grid", with([]string{"view", "update", "fill-color-rule"}, "--json={}")},
{"fill valid", "Grid", with([]string{"view", "update", "fill-color-rule"}, "--json=[]")},
{"fill missing base", "Grid", []string{"view", "update", "fill-color-rule", "--table-id=t", "--view-id=view", "--json=[]"}},
}
for _, item := range scenarios {
t.Run(item.name, func(t *testing.T) {
if err := runAitableCoverageCommand(t, &aitableCommandCoverageCaller{viewType: item.viewType}, item.args...); err != nil {
t.Logf("command returned: %v", err)
}
})
}
// Exercise the shared view preflight's base-ID and transport-error exits.
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, "view", "update", "visible-fields", "--table-id=t", "--view-id=view", "--field-ids=f")
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{err: fmt.Errorf("transport")}, with([]string{"view", "update", "card"}, "--no-cover")...)
}
func TestCrossPlatformCoverageAitableDeleteCancellationEdges(t *testing.T) {
oldDeps, oldArgs, oldStdin := deps, os.Args, os.Stdin
t.Cleanup(func() { deps, os.Args, os.Stdin = oldDeps, oldArgs, oldStdin })
os.Args = []string{"dws", "aitable"}
input, err := os.CreateTemp(t.TempDir(), "answers")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = input.Close() })
if _, err := input.WriteString(strings.Repeat("no\n", 20)); err != nil {
t.Fatal(err)
}
if _, err := input.Seek(0, 0); err != nil {
t.Fatal(err)
}
os.Stdin = input
commands := [][]string{
{"base", "delete", "--base-id=b"},
{"table", "delete", "--base-id=b", "--table-id=t"},
{"field", "delete", "--base-id=b", "--table-id=t", "--field-id=f"},
{"record", "delete", "--base-id=b", "--table-id=t", "--record-ids=r"},
{"view", "delete", "--base-id=b", "--table-id=t", "--view-id=v"},
{"form", "delete", "--base-id=b", "--table-id=t", "--view-id=v"},
{"workflow", "disable", "--base-id=b", "--workflow-id=w"},
{"dashboard", "delete", "--base-id=b", "--dashboard-id=d"},
{"chart", "delete", "--base-id=b", "--dashboard-id=d", "--chart-id=c"},
{"advperm", "disable", "--base-id=b"},
{"advperm", "role-delete", "--base-id=b", "--role-id=r"},
}
for _, args := range commands {
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...)
}
}