279 lines
12 KiB
Go
279 lines
12 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package helpers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
)
|
|
|
|
func runAitableStatsCLI(t *testing.T, args ...string) (*aitableTestCaller, error) {
|
|
t.Helper()
|
|
caller := &aitableTestCaller{}
|
|
installAitableDeps(t, caller)
|
|
command := newAitableCommand()
|
|
command.SilenceErrors = true
|
|
command.SilenceUsage = true
|
|
command.SetArgs(args)
|
|
return caller, command.Execute()
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableRecordsStatsForwardsValidatedContract(t *testing.T) {
|
|
caller, err := runAitableStatsCLI(t,
|
|
"record", "stats",
|
|
"--base-id", "base-stats",
|
|
"--table-id", "table-stats",
|
|
"--stats", `[{"fieldId":"fldCount","statsType":"COUNT"},{"fieldId":"fldAmount","statsType":"SUM"}]`,
|
|
"--filters", `{"operator":"and","operands":[{"operator":"gt","operands":["fldAmount",0]}]}`,
|
|
"--sort", `[{"fieldId":"fldAmount","direction":"DESC"}]`,
|
|
"--limit", "500",
|
|
"--keyword", "华东",
|
|
"--search-field-ids", "fldName,fldRegion",
|
|
"--data-version", "42",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("record stats returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
|
|
}
|
|
call := caller.calls[0]
|
|
if call.server != "aitable" || call.tool != "query_records_stats" {
|
|
t.Fatalf("tool = %s/%s, want aitable/query_records_stats", call.server, call.tool)
|
|
}
|
|
wantStats := []map[string]any{
|
|
{"fieldId": "fldCount", "statsType": "COUNT"},
|
|
{"fieldId": "fldAmount", "statsType": "SUM"},
|
|
}
|
|
if !reflect.DeepEqual(call.args["stats"], wantStats) {
|
|
t.Fatalf("stats = %#v, want %#v", call.args["stats"], wantStats)
|
|
}
|
|
filters := call.args["filters"].(map[string]any)
|
|
condition := filters["operands"].([]any)[0].(map[string]any)
|
|
if got := condition["operands"].([]any)[1]; got != json.Number("0") {
|
|
t.Fatalf("numeric filter value = %#v (%T), want json.Number(0)", got, got)
|
|
}
|
|
if call.args["sort"] != `[{"fieldId":"fldAmount","direction":"DESC"}]` ||
|
|
call.args["limit"] != 500 || call.args["keyword"] != "华东" || call.args["dataVersion"] != "42" {
|
|
t.Fatalf("forwarded args = %#v", call.args)
|
|
}
|
|
if !reflect.DeepEqual(call.args["searchFieldIds"], []string{"fldName", "fldRegion"}) {
|
|
t.Fatalf("searchFieldIds = %#v", call.args["searchFieldIds"])
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableGroupStatsForwardsStringDSL(t *testing.T) {
|
|
group := `[{"fieldId":"fldRegion","direction":"ASC","fieldConfig":null,"arraySplitMode":true}]`
|
|
sortDSL := `[{"fieldId":"fldAmount","direction":"DESC"}]`
|
|
caller, err := runAitableStatsCLI(t,
|
|
"record", "group-stats",
|
|
"--base-id", "base-stats",
|
|
"--table-id", "table-stats",
|
|
"--stats", `[{"fieldId":"fldStore","statsType":"distinct"},{"fieldId":"fldAmount","statsType":"avg"}]`,
|
|
"--filters", `{"operator":"OR","operands":[{"operator":"not_before","operands":["fldDate","2026-01-01"]}]}`,
|
|
"--group", group,
|
|
"--sort", sortDSL,
|
|
"--limit", "1000",
|
|
"--data-version", "43",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("record group-stats returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
|
|
}
|
|
call := caller.calls[0]
|
|
if call.server != "aitable" || call.tool != "query_stats" {
|
|
t.Fatalf("tool = %s/%s, want aitable/query_stats", call.server, call.tool)
|
|
}
|
|
if call.args["group"] != group || call.args["sortDsl"] != sortDSL || call.args["limit"] != 1000 || call.args["dataVersion"] != "43" {
|
|
t.Fatalf("forwarded args = %#v", call.args)
|
|
}
|
|
stats := call.args["stats"].([]map[string]any)
|
|
if len(stats) != 2 || stats[0]["statsType"] != "distinct" || stats[1]["statsType"] != "avg" {
|
|
t.Fatalf("stats = %#v", stats)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableStatsRejectsInvalidInputsBeforeCallingMCP(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "records stats require uppercase",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`},
|
|
wantErr: "大写",
|
|
},
|
|
{
|
|
name: "records stats require at least one item",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[]`},
|
|
wantErr: "至少需要一个统计项",
|
|
},
|
|
{
|
|
name: "records stats enforce item cap",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[
|
|
{"fieldId":"f01","statsType":"COUNT"},{"fieldId":"f02","statsType":"COUNT"},
|
|
{"fieldId":"f03","statsType":"COUNT"},{"fieldId":"f04","statsType":"COUNT"},
|
|
{"fieldId":"f05","statsType":"COUNT"},{"fieldId":"f06","statsType":"COUNT"},
|
|
{"fieldId":"f07","statsType":"COUNT"},{"fieldId":"f08","statsType":"COUNT"},
|
|
{"fieldId":"f09","statsType":"COUNT"},{"fieldId":"f10","statsType":"COUNT"},
|
|
{"fieldId":"f11","statsType":"COUNT"},{"fieldId":"f12","statsType":"COUNT"},
|
|
{"fieldId":"f13","statsType":"COUNT"},{"fieldId":"f14","statsType":"COUNT"},
|
|
{"fieldId":"f15","statsType":"COUNT"},{"fieldId":"f16","statsType":"COUNT"},
|
|
{"fieldId":"f17","statsType":"COUNT"},{"fieldId":"f18","statsType":"COUNT"},
|
|
{"fieldId":"f19","statsType":"COUNT"},{"fieldId":"f20","statsType":"COUNT"},
|
|
{"fieldId":"f21","statsType":"COUNT"}
|
|
]`},
|
|
wantErr: "单次最多支持 20 个统计项",
|
|
},
|
|
{
|
|
name: "records stats require item fields",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":" ","statsType":"COUNT"}]`},
|
|
wantErr: "均不能为空",
|
|
},
|
|
{
|
|
name: "records stats reject unknown item fields",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT","unknownKey":123}]`},
|
|
wantErr: "不支持的字段",
|
|
},
|
|
{
|
|
name: "records stats reject duplicate field",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"},{"fieldId":"f","statsType":"SUM"}]`},
|
|
wantErr: "重复",
|
|
},
|
|
{
|
|
name: "group stats require lowercase",
|
|
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`},
|
|
wantErr: "小写",
|
|
},
|
|
{
|
|
name: "group must be array DSL",
|
|
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--group", `{}`},
|
|
wantErr: "JSON 数组",
|
|
},
|
|
{
|
|
name: "group limit capped",
|
|
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--limit", "1001"},
|
|
wantErr: "[1, 1000]",
|
|
},
|
|
{
|
|
name: "filters require logical root",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"eq","operands":[]}`},
|
|
wantErr: `root "operator" must be "and" or "or"`,
|
|
},
|
|
{
|
|
name: "records filters reject malformed JSON",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{`},
|
|
wantErr: "必须是 JSON 对象",
|
|
},
|
|
{
|
|
name: "records filters reject null",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `null`},
|
|
wantErr: "不能是 null",
|
|
},
|
|
{
|
|
name: "records filters reject a second JSON value",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} {}`},
|
|
wantErr: "只能包含一个 JSON 对象",
|
|
},
|
|
{
|
|
name: "records filters reject invalid trailing content",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} trailing`},
|
|
wantErr: "无效的尾随内容",
|
|
},
|
|
{
|
|
name: "records filters require operand array",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":{}}`},
|
|
wantErr: `"operands" must be an array`,
|
|
},
|
|
{
|
|
name: "records filters reject silently unsupported operators",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
|
|
wantErr: `unsupported filter operator "date_between"`,
|
|
},
|
|
{
|
|
name: "records sort requires an item",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[]`},
|
|
wantErr: "至少需要一个条目",
|
|
},
|
|
{
|
|
name: "records sort items must be objects",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[1]`},
|
|
wantErr: "必须是 JSON 对象",
|
|
},
|
|
{
|
|
name: "records limit must be positive",
|
|
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--limit", "0"},
|
|
wantErr: "必须大于 0",
|
|
},
|
|
{
|
|
name: "group filters reject malformed JSON",
|
|
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{`},
|
|
wantErr: "必须是 JSON 对象",
|
|
},
|
|
{
|
|
name: "group filters require logical root",
|
|
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"eq","operands":[]}`},
|
|
wantErr: `root "operator" must be "and" or "or"`,
|
|
},
|
|
{
|
|
name: "group filters reject silently unsupported operators",
|
|
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
|
|
wantErr: `unsupported filter operator "date_between"`,
|
|
},
|
|
{
|
|
name: "group sort rejects invalid DSL",
|
|
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--sort", `{}`},
|
|
wantErr: "JSON 数组",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
caller, err := runAitableStatsCLI(t, test.args...)
|
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
|
t.Fatalf("error = %v, want containing %q", err, test.wantErr)
|
|
}
|
|
var structured *apperrors.Error
|
|
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation {
|
|
t.Fatalf("error = %#v, want structured validation error", err)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("tool calls = %d, want 0", len(caller.calls))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableStatsPublishInterfaceAndResultContracts(t *testing.T) {
|
|
root := newAitableCommand()
|
|
tests := []struct {
|
|
path string
|
|
rpc string
|
|
}{
|
|
{path: "aitable record stats", rpc: "query_records_stats"},
|
|
{path: "aitable record group-stats", rpc: "query_stats"},
|
|
}
|
|
for _, test := range tests {
|
|
leaf := findCLIPath(root, test.path)
|
|
if leaf == nil {
|
|
t.Fatalf("missing leaf %q", test.path)
|
|
}
|
|
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
|
if !ok || final.Interface == nil || final.Interface.Ref == nil || final.Interface.Ref.RPCName != test.rpc {
|
|
t.Fatalf("%s interface = %#v", test.path, final.Interface)
|
|
}
|
|
if final.Result == nil || len(final.Result.Outcomes) == 0 || len(final.Result.DataSchema) == 0 {
|
|
t.Fatalf("%s result = %#v", test.path, final.Result)
|
|
}
|
|
}
|
|
}
|