316 lines
12 KiB
Go
316 lines
12 KiB
Go
package helpers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type aitableTestCall struct {
|
|
server string
|
|
tool string
|
|
args map[string]any
|
|
}
|
|
|
|
type aitableTestCaller struct {
|
|
responses []string
|
|
errors []error
|
|
calls []aitableTestCall
|
|
}
|
|
|
|
func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
|
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args})
|
|
index := len(c.calls) - 1
|
|
if index < len(c.errors) && c.errors[index] != nil {
|
|
return nil, c.errors[index]
|
|
}
|
|
response := `{"success":true}`
|
|
if index < len(c.responses) {
|
|
response = c.responses[index]
|
|
}
|
|
return textToolResult(response), nil
|
|
}
|
|
func (*aitableTestCaller) Format() string { return "json" }
|
|
func (*aitableTestCaller) DryRun() bool { return false }
|
|
func (*aitableTestCaller) Fields() string { return "" }
|
|
func (*aitableTestCaller) JQ() string { return "" }
|
|
|
|
func installAitableDeps(t *testing.T, caller *aitableTestCaller) *bytes.Buffer {
|
|
t.Helper()
|
|
oldDeps, oldArgs := deps, os.Args
|
|
t.Cleanup(func() { deps, os.Args = oldDeps, oldArgs })
|
|
InitDeps(caller)
|
|
out := &bytes.Buffer{}
|
|
deps.Out.w, deps.Out.errW = out, out
|
|
os.Args = []string{"dws", "aitable"}
|
|
return out
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableFlagAndJSONNormalizers(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("enabled", "", "")
|
|
cmd.Flags().String("records", "", "")
|
|
cmd.Flags().String("records-file", "", "")
|
|
cmd.Flags().String("fields", "", "")
|
|
if err := cmd.Flags().Set("enabled", "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, err := parseBoolFlag(cmd, "enabled"); err != nil || !got {
|
|
t.Fatalf("parseBoolFlag true = %v, %v", got, err)
|
|
}
|
|
_ = cmd.Flags().Set("enabled", "invalid")
|
|
if _, err := parseBoolFlag(cmd, "enabled"); err == nil {
|
|
t.Fatal("invalid boolean should fail")
|
|
}
|
|
|
|
if _, err := resolveRecordsFlag(cmd); err == nil {
|
|
t.Fatal("missing records should fail")
|
|
}
|
|
_ = cmd.Flags().Set("fields", `[{"cells":{}}]`)
|
|
if got, err := resolveRecordsFlag(cmd); err != nil || got == "" {
|
|
t.Fatalf("fields alias = %q, %v", got, err)
|
|
}
|
|
_ = cmd.Flags().Set("records", `[{"cells":{"x":1}}]`)
|
|
if got, _ := resolveRecordsFlag(cmd); !strings.Contains(got, "x") {
|
|
t.Fatalf("records value = %q", got)
|
|
}
|
|
dir := t.TempDir()
|
|
empty := filepath.Join(dir, "empty.json")
|
|
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = cmd.Flags().Set("records-file", empty)
|
|
if _, err := resolveRecordsFlag(cmd); err == nil {
|
|
t.Fatal("empty records file should fail")
|
|
}
|
|
_ = cmd.Flags().Set("records-file", filepath.Join(dir, "missing"))
|
|
if _, err := resolveRecordsFlag(cmd); err == nil {
|
|
t.Fatal("missing records file should fail")
|
|
}
|
|
valid := filepath.Join(dir, "records.json")
|
|
if err := os.WriteFile(valid, []byte(" [] \n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = cmd.Flags().Set("records-file", valid)
|
|
if got, err := resolveRecordsFlag(cmd); err != nil || got != "[]" {
|
|
t.Fatalf("records file = %q, %v", got, err)
|
|
}
|
|
|
|
filterCases := []any{
|
|
nil,
|
|
[]any{},
|
|
map[string]any{},
|
|
map[string]any{"operator": 1},
|
|
map[string]any{"operator": "eq"},
|
|
map[string]any{"operator": "and"},
|
|
map[string]any{"operator": "and", "operands": "bad"},
|
|
map[string]any{"operator": "and", "operands": []any{"skip", map[string]any{}, map[string]any{"operator": 1}}},
|
|
map[string]any{"operator": "and", "operands": []any{map[string]any{"operator": "date_between"}}},
|
|
map[string]any{"operator": "and", "operands": []any{map[string]any{"operator": "equals"}}},
|
|
map[string]any{"operator": "and", "operands": []any{map[string]any{"operator": "eq"}}},
|
|
}
|
|
for index, value := range filterCases {
|
|
err := validateFiltersStructure(value, "unused")
|
|
if index == 0 || index == len(filterCases)-1 || index == 7 {
|
|
if err != nil {
|
|
t.Errorf("valid filter case %d: %v", index, err)
|
|
}
|
|
} else if err == nil {
|
|
t.Errorf("invalid filter case %d should fail", index)
|
|
}
|
|
}
|
|
|
|
input := map[string]any{"operator": "and", "operands": []any{
|
|
"plain",
|
|
map[string]any{"fieldId": "f1", "operator": "eq", "value": "v"},
|
|
map[string]any{"fieldId": "f2", "operator": "exist"},
|
|
map[string]any{"operator": "or", "operands": []any{map[string]any{"fieldId": "f3", "operator": "eq", "value": 3}}},
|
|
map[string]any{"operator": "eq"},
|
|
}}
|
|
normalized := normalizeFilters(input).(map[string]any)
|
|
if len(normalized["operands"].([]any)) != 5 {
|
|
t.Fatalf("normalized filters = %#v", normalized)
|
|
}
|
|
for _, value := range []any{nil, "plain", map[string]any{}, map[string]any{"operands": "bad"}} {
|
|
_ = normalizeFilters(value)
|
|
}
|
|
if got := normalizeViewConfigFilter(input); reflect.TypeOf(got).Kind() != reflect.Slice {
|
|
t.Fatalf("view filter object = %#v", got)
|
|
}
|
|
_ = normalizeViewConfigFilter([]any{input})
|
|
_ = normalizeViewConfigFilter("plain")
|
|
for _, value := range []any{[]any{1}, map[string]any{"x": 1}, "plain"} {
|
|
_ = ensureArray(value)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableViewConfigAndHelpers(t *testing.T) {
|
|
config := map[string]any{
|
|
"filter": map[string]any{"operator": "and", "operands": []any{}},
|
|
"sort": map[string]any{"fieldId": "f"}, "group": []any{},
|
|
"flags": true, "unknown": true,
|
|
}
|
|
if err := normalizeViewConfigBlock(config); err != nil {
|
|
t.Fatalf("normalize view config: %v", err)
|
|
}
|
|
for _, key := range []string{"filter", "sort", "group"} {
|
|
if reflect.TypeOf(config[key]).Kind() != reflect.Slice {
|
|
t.Errorf("config %s = %#v", key, config[key])
|
|
}
|
|
}
|
|
for _, bad := range []map[string]any{{"filter": 1}, {"sort": 1}, {"group": 1}} {
|
|
if err := normalizeViewConfigBlock(bad); err == nil {
|
|
t.Errorf("invalid view config %#v should fail", bad)
|
|
}
|
|
}
|
|
|
|
for op, want := range map[string]string{"eq": "", "equals": "eq", "unknown": "eq"} {
|
|
if got := suggestOperator(op); got != want {
|
|
t.Errorf("suggestOperator(%q) = %q, want %q", op, got, want)
|
|
}
|
|
}
|
|
for _, tc := range []struct {
|
|
err error
|
|
want bool
|
|
}{{nil, false}, {errors.New("timeout"), true}, {errors.New("SYSTEM_ERROR"), true}, {errors.New("retryable: true"), true}, {errors.New("bad request"), false}} {
|
|
if got := isAitableRetryableError(tc.err); got != tc.want {
|
|
t.Errorf("isAitableRetryableError(%v) = %v", tc.err, got)
|
|
}
|
|
}
|
|
|
|
if requireViewType("Grid", "sort", []string{"Grid"}) != nil || requireViewType("Gallery", "sort", []string{"Grid"}) == nil {
|
|
t.Fatal("view type requirement mismatch")
|
|
}
|
|
for viewType, want := range map[string]string{"Kanban": "kanbanCard", "Gallery": "galleryCard"} {
|
|
got, err := dispatchCardKey(viewType)
|
|
if err != nil || got != want {
|
|
t.Errorf("dispatchCardKey(%q) = %q, %v", viewType, got, err)
|
|
}
|
|
}
|
|
if _, err := dispatchCardKey("Grid"); err == nil {
|
|
t.Fatal("Grid card should fail")
|
|
}
|
|
view := map[string]any{"custom": map[string]any{"width": 10}, "scalar": 1}
|
|
if walkViewPath(view, "custom.width") != 10 || walkViewPath(view, "") != nil || walkViewPath(view, "scalar.child") != nil {
|
|
t.Fatal("walkViewPath mismatch")
|
|
}
|
|
forms := map[string]any{"data": []any{map[string]any{"viewId": "target"}}}
|
|
if found, ok := findFormViewByID(forms, "target"); !ok || found["viewId"] != "target" {
|
|
t.Fatalf("find form = %#v, %v", found, ok)
|
|
}
|
|
if _, ok := findFormViewByID([]any{"plain"}, "missing"); ok {
|
|
t.Fatal("missing form should not be found")
|
|
}
|
|
|
|
cmd := &cobra.Command{Use: "flags"}
|
|
cmd.Flags().String("name", "", "")
|
|
cmd.Flags().String("title", "", "")
|
|
cmd.Flags().String("text", "", "")
|
|
cmd.Flags().Bool("enabled", false, "")
|
|
out := map[string]any{}
|
|
collectStringFlag(cmd, "text", "text", out)
|
|
collectBoolFlag(cmd, "enabled", "enabled", out)
|
|
_ = cmd.Flags().Set("text", "value")
|
|
_ = cmd.Flags().Set("enabled", "false")
|
|
collectStringFlag(cmd, "text", "text", out)
|
|
collectBoolFlag(cmd, "enabled", "enabled", out)
|
|
if out["text"] != "value" || out["enabled"] != false {
|
|
t.Fatalf("collected flags = %#v", out)
|
|
}
|
|
if resolveFormUpdateTitle(cmd) != "" {
|
|
t.Fatal("empty form title should stay empty")
|
|
}
|
|
_ = cmd.Flags().Set("name", "name")
|
|
if resolveFormUpdateTitle(cmd) != "name" {
|
|
t.Fatal("form name fallback failed")
|
|
}
|
|
_ = cmd.Flags().Set("title", "title")
|
|
if resolveFormUpdateTitle(cmd) != "title" {
|
|
t.Fatal("form title priority failed")
|
|
}
|
|
|
|
if _, err := mergeUpdateBlock(`[]`, nil); err == nil {
|
|
t.Fatal("non-object update JSON should fail")
|
|
}
|
|
merged, err := mergeUpdateBlock(`{"x":1,"same":"json"}`, map[string]any{"same": "typed", "y": 2})
|
|
if err != nil || merged["same"] != "typed" || merged["y"] != 2 {
|
|
t.Fatalf("merged update = %#v, %v", merged, err)
|
|
}
|
|
if merged, err := mergeUpdateBlock("", map[string]any{"x": 1}); err != nil || merged["x"] != 1 {
|
|
t.Fatalf("typed-only update = %#v, %v", merged, err)
|
|
}
|
|
|
|
for _, raw := range []string{`[1]`, `{"fields":[1]}`, `{}`, `{`} {
|
|
fields, err := parseFieldsJSON(raw)
|
|
if (raw == `[1]` || strings.Contains(raw, "fields")) && (err != nil || len(fields) != 1) {
|
|
t.Errorf("parseFieldsJSON(%q) = %#v, %v", raw, fields, err)
|
|
}
|
|
if (raw == `{}` || raw == `{`) && err == nil {
|
|
t.Errorf("parseFieldsJSON(%q) should fail", raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableToolResponseAndPaginationHelpers(t *testing.T) {
|
|
caller := &aitableTestCaller{responses: []string{`{"data":{"views":[{"viewId":"v","viewType":"Grid"}]}}`}}
|
|
installAitableDeps(t, caller)
|
|
view, viewType, err := getViewRaw(context.Background(), "b", "t", "v")
|
|
if err != nil || viewType != "Grid" || view["viewId"] != "v" {
|
|
t.Fatalf("getViewRaw success = %#v, %q, %v", view, viewType, err)
|
|
}
|
|
|
|
for _, response := range []string{"{", `{"data":{"views":[]}}`, `{"data":{"views":["bad"]}}`} {
|
|
caller = &aitableTestCaller{responses: []string{response}}
|
|
installAitableDeps(t, caller)
|
|
if _, _, err := getViewRaw(context.Background(), "b", "t", "v"); err == nil {
|
|
t.Errorf("getViewRaw(%q) should fail", response)
|
|
}
|
|
}
|
|
caller = &aitableTestCaller{errors: []error{errors.New("offline")}}
|
|
installAitableDeps(t, caller)
|
|
if _, _, err := getViewRaw(context.Background(), "b", "t", "v"); err == nil {
|
|
t.Fatal("getViewRaw transport error should fail")
|
|
}
|
|
|
|
caller = &aitableTestCaller{}
|
|
out := installAitableDeps(t, caller)
|
|
if err := printViewSubBlock(nil); err != nil || !strings.Contains(out.String(), "status") {
|
|
t.Fatalf("print view sub-block = %q, %v", out.String(), err)
|
|
}
|
|
if err := callUpdateViewWithBlock("b", "t", "v", "kanbanCard", map[string]any{"x": 1}, map[string]any{"extra": true}); err != nil {
|
|
t.Fatalf("update view block: %v", err)
|
|
}
|
|
if err := callUpdateViewWithBlock("b", "t", "v", "", nil, map[string]any{"newViewName": "name"}); err != nil {
|
|
t.Fatalf("update view top-level: %v", err)
|
|
}
|
|
|
|
caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"totalCount":17}}`}}
|
|
out = installAitableDeps(t, caller)
|
|
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), `"totalCount": 17`) || !strings.Contains(out.String(), `"fetchedCount": 1`) {
|
|
t.Fatalf("paginated records = %q, %v", out.String(), err)
|
|
}
|
|
caller = &aitableTestCaller{responses: []string{"not-json"}}
|
|
out = installAitableDeps(t, caller)
|
|
if err := recordQueryFetchAll(map[string]any{}, 1); err == nil || out.Len() != 0 {
|
|
t.Fatalf("invalid first page must fail without success output = %q, %v", out.String(), err)
|
|
}
|
|
caller = &aitableTestCaller{responses: []string{`{"records":[{"id":1}]}`}}
|
|
installAitableDeps(t, caller)
|
|
if err := recordQueryFetchAll(map[string]any{}, 0); err != nil {
|
|
t.Fatalf("flat records pagination: %v", err)
|
|
}
|
|
caller = &aitableTestCaller{errors: []error{errors.New("offline")}}
|
|
installAitableDeps(t, caller)
|
|
if err := recordQueryFetchAll(map[string]any{}, 1); err == nil {
|
|
t.Fatal("first-page pagination error should fail")
|
|
}
|
|
}
|