Files
dingtalk-workspace-cli/internal/helpers/aitable_core_test.go
T

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")
}
}