Files
dingtalk-workspace-cli/internal/helpers/aitable_stats_test.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)
}
}
}