428 lines
16 KiB
Go
428 lines
16 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
|
|
package helpers
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
)
|
|
|
|
type aitableWorkflowCall struct {
|
|
productID string
|
|
toolName string
|
|
args map[string]any
|
|
}
|
|
|
|
type aitableWorkflowCaller struct {
|
|
calls []aitableWorkflowCall
|
|
response string
|
|
err error
|
|
dryRun bool
|
|
}
|
|
|
|
func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
|
c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args})
|
|
if c.err != nil {
|
|
return nil, c.err
|
|
}
|
|
response := c.response
|
|
if response == "" {
|
|
response = `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`
|
|
}
|
|
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
|
Type: "text",
|
|
Text: response,
|
|
}}}, nil
|
|
}
|
|
|
|
func (*aitableWorkflowCaller) Format() string { return "json" }
|
|
func (c *aitableWorkflowCaller) DryRun() bool { return c.dryRun }
|
|
func (*aitableWorkflowCaller) Fields() string { return "" }
|
|
func (*aitableWorkflowCaller) JQ() string { return "" }
|
|
|
|
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
|
|
t.Helper()
|
|
caller := &aitableWorkflowCaller{}
|
|
return caller, runAitableWorkflowCommandWithCaller(t, caller, stdin, args...)
|
|
}
|
|
|
|
func runAitableWorkflowCommandWithCaller(t *testing.T, caller *aitableWorkflowCaller, stdin io.Reader, args ...string) error {
|
|
t.Helper()
|
|
testseam.Protect(t, &os.Args)
|
|
|
|
InitDepsForTest(t, caller)
|
|
deps.Out.w = io.Discard
|
|
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
|
|
|
|
cmd := newAitableCommand()
|
|
cmd.PersistentFlags().String("format", "json", "output format")
|
|
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
|
cmd.PersistentFlags().Bool("dry-run", false, "preview only")
|
|
cmd.SilenceErrors = true
|
|
cmd.SilenceUsage = true
|
|
cmd.SetArgs(append([]string{"workflow"}, args...))
|
|
if stdin == nil {
|
|
stdin = strings.NewReader("")
|
|
}
|
|
cmd.SetIn(stdin)
|
|
return cmd.Execute()
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
|
wantDSL := map[string]any{
|
|
"version": "workflow-dsl/v1",
|
|
"name": "create test",
|
|
}
|
|
caller, err := runAitableWorkflowCommand(t, nil,
|
|
"create",
|
|
"--base-id", "base-create",
|
|
"--dsl", `{"version":"workflow-dsl/v1","name":"create test"}`,
|
|
"--locale", "zh-CN",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("workflow create returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
|
}
|
|
call := caller.calls[0]
|
|
if call.productID != "aitable" || call.toolName != "create_workflow" {
|
|
t.Fatalf("tool call = %s/%s, want aitable/create_workflow", call.productID, call.toolName)
|
|
}
|
|
wantArgs := map[string]any{
|
|
"baseId": "base-create",
|
|
"dsl": wantDSL,
|
|
"locale": "zh-CN",
|
|
}
|
|
if !reflect.DeepEqual(call.args, wantArgs) {
|
|
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
response string
|
|
want string
|
|
}{
|
|
{name: "valid false", response: `{"status":"success","data":{"valid":false,"issues":[{"message":"bad dsl"}]}}`, want: "valid=false"},
|
|
{name: "missing valid", response: `{"status":"success","data":{"flowId":"flow-test"}}`, want: "missing valid"},
|
|
{name: "missing flow id", response: `{"status":"success","data":{"valid":true}}`, want: "missing a non-empty flowId"},
|
|
{name: "wrong valid type", response: `{"status":"success","data":{"valid":"true","flowId":"flow-test"}}`, want: "valid must be boolean"},
|
|
{name: "malformed response", response: `{`, want: "not a JSON object"},
|
|
{name: "null response", response: `null`, want: "not a JSON object"},
|
|
{name: "conflicting valid", response: `{"valid":true,"data":{"valid":false,"flowId":"flow-test"}}`, want: "conflicting valid values"},
|
|
{name: "invalid flow id", response: `{"valid":true,"flowId":1}`, want: "flowId must be a non-empty string"},
|
|
{name: "conflicting workflow ids", response: `{"valid":true,"flowId":"one","data":{"workflowId":"two"}}`, want: "conflicting workflow IDs"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
caller := &aitableWorkflowCaller{response: tc.response}
|
|
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
|
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) || len(caller.calls) != 1 {
|
|
t.Fatalf("workflow false success = err:%v calls:%#v", err, caller.calls)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("update uses the same strict contract", func(t *testing.T) {
|
|
caller := &aitableWorkflowCaller{response: `{"status":"success","data":{"valid":false}}`}
|
|
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
|
"update", "--base-id", "base", "--workflow-id", "flow", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
|
if err == nil || !strings.Contains(err.Error(), "valid=false") || len(caller.calls) != 1 || caller.calls[0].toolName != "update_workflow" {
|
|
t.Fatalf("workflow update false success = err:%v calls:%#v", err, caller.calls)
|
|
}
|
|
})
|
|
|
|
t.Run("dry-run does not call publish tool", func(t *testing.T) {
|
|
caller := &aitableWorkflowCaller{dryRun: true}
|
|
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
|
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`, "--dry-run")
|
|
if err != nil || len(caller.calls) != 0 {
|
|
t.Fatalf("workflow create dry-run = err:%v calls:%#v", err, caller.calls)
|
|
}
|
|
})
|
|
|
|
t.Run("transport error", func(t *testing.T) {
|
|
caller := &aitableWorkflowCaller{err: context.Canceled}
|
|
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
|
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
|
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 1 {
|
|
t.Fatalf("workflow transport error = err:%v calls:%#v", err, caller.calls)
|
|
}
|
|
})
|
|
|
|
if _, _, _, err := strictAitableWorkflowPublishResult(nil); err == nil || !strings.Contains(err.Error(), "empty response") {
|
|
t.Fatalf("nil workflow envelope = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
|
|
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
|
|
if err != nil {
|
|
t.Fatalf("workflow edit-example returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
|
}
|
|
call := caller.calls[0]
|
|
if call.productID != "aitable" || call.toolName != "edit_workflow_example" {
|
|
t.Fatalf("tool call = %s/%s, want aitable/edit_workflow_example", call.productID, call.toolName)
|
|
}
|
|
if len(call.args) != 0 {
|
|
t.Fatalf("tool args = %#v, want empty arguments", call.args)
|
|
}
|
|
}
|
|
|
|
func TestAitableWorkflowUpdateReadsDSLFile(t *testing.T) {
|
|
path := t.TempDir() + "/workflow.json"
|
|
if err := os.WriteFile(path, []byte(`{"version":"workflow-dsl/v1","name":"updated"}`), 0o600); err != nil {
|
|
t.Fatalf("write workflow fixture: %v", err)
|
|
}
|
|
caller, err := runAitableWorkflowCommand(t, nil,
|
|
"update",
|
|
"--base-id", "base-update",
|
|
"--workflow-id", "flow-existing",
|
|
"--dsl", "@"+path,
|
|
"--locale", "en-US",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("workflow update returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
|
}
|
|
call := caller.calls[0]
|
|
if call.productID != "aitable" || call.toolName != "update_workflow" {
|
|
t.Fatalf("tool call = %s/%s, want aitable/update_workflow", call.productID, call.toolName)
|
|
}
|
|
wantArgs := map[string]any{
|
|
"baseId": "base-update",
|
|
"workflowId": "flow-existing",
|
|
"locale": "en-US",
|
|
"dsl": map[string]any{
|
|
"version": "workflow-dsl/v1",
|
|
"name": "updated",
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(call.args, wantArgs) {
|
|
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
|
}
|
|
}
|
|
|
|
func TestAitableWorkflowWriteReportsStdinReadError(t *testing.T) {
|
|
caller, err := runAitableWorkflowCommand(t, coverageFailingReader{},
|
|
"create", "--base-id", "base-stdin", "--dsl", "-",
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), "--dsl stdin read failed: read failed") {
|
|
t.Fatalf("error = %v, want wrapped stdin read error", err)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("failed stdin read reached MCP: %#v", caller.calls)
|
|
}
|
|
}
|
|
|
|
func TestAitableWorkflowCreateReadsDSLFromStdin(t *testing.T) {
|
|
caller, err := runAitableWorkflowCommand(t,
|
|
strings.NewReader(`{"version":"workflow-dsl/v1","name":"stdin"}`),
|
|
"create", "--base-id", "base-stdin", "--dsl", "-",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("workflow create from stdin returned error: %v", err)
|
|
}
|
|
if got := caller.calls[0].args["dsl"].(map[string]any)["name"]; got != "stdin" {
|
|
t.Fatalf("dsl name = %#v, want stdin", got)
|
|
}
|
|
}
|
|
|
|
func TestAitableWorkflowWriteRejectsInvalidInput(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{name: "create missing dsl", args: []string{"create", "--base-id", "base"}, want: "dsl"},
|
|
{name: "update missing workflow", args: []string{"update", "--base-id", "base", "--dsl", `{}`}, want: "workflow-id"},
|
|
{name: "malformed json", args: []string{"create", "--base-id", "base", "--dsl", "{not-json"}, want: "JSON parse failed"},
|
|
{name: "array", args: []string{"create", "--base-id", "base", "--dsl", `[]`}, want: "JSON parse failed"},
|
|
{name: "null", args: []string{"create", "--base-id", "base", "--dsl", `null`}, want: "JSON object"},
|
|
{name: "empty file path", args: []string{"create", "--base-id", "base", "--dsl", "@"}, want: "file path"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("error = %v, want substring %q", err, tc.want)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("invalid input reached MCP: %#v", caller.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableWorkflowRunMapsRecordTrigger(t *testing.T) {
|
|
caller, err := runAitableWorkflowCommand(t, nil,
|
|
"run",
|
|
"--base-id", "base-run",
|
|
"--workflow-id", "workflow-run",
|
|
"--table-id", "table-run",
|
|
"--record-ids", "record-1,record-2",
|
|
"--yes",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("workflow run returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
|
}
|
|
call := caller.calls[0]
|
|
if call.productID != "aitable" || call.toolName != "run_workflow" {
|
|
t.Fatalf("tool call = %s/%s, want aitable/run_workflow", call.productID, call.toolName)
|
|
}
|
|
wantArgs := map[string]any{
|
|
"baseId": "base-run",
|
|
"workflowId": "workflow-run",
|
|
"tableId": "table-run",
|
|
"recordIds": []string{"record-1", "record-2"},
|
|
}
|
|
if !reflect.DeepEqual(call.args, wantArgs) {
|
|
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableWorkflowRunMapsScheduledTrigger(t *testing.T) {
|
|
caller, err := runAitableWorkflowCommand(t, nil,
|
|
"run", "--base", "base-scheduled", "--workflow-id", "workflow-scheduled", "--yes",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("scheduled workflow run returned error: %v", err)
|
|
}
|
|
wantArgs := map[string]any{
|
|
"baseId": "base-scheduled",
|
|
"workflowId": "workflow-scheduled",
|
|
}
|
|
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
|
t.Fatalf("calls = %#v, want one scheduled invocation %#v", caller.calls, wantArgs)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableWorkflowRunRejectsUnsafeOrInvalidInput(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{name: "confirmation", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow"}, want: "用户确认"},
|
|
{name: "blank table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", " ", "--yes"}, want: "--table-id 不能为空"},
|
|
{name: "blank records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", " , ", "--yes"}, want: "--record-ids 必须包含"},
|
|
{name: "table without records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--yes"}, want: "必须同时提供"},
|
|
{name: "records without table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", "record", "--yes"}, want: "必须同时提供"},
|
|
{name: "duplicate records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "record,record", "--yes"}, want: "不能包含重复值"},
|
|
{name: "too many records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "r1,r2,r3,r4,r5,r6", "--yes"}, want: "最多支持 5 个"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
|
|
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.want)) {
|
|
t.Fatalf("error = %v, want substring %q", err, tc.want)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("invalid run reached MCP: %#v", caller.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableWorkflowHistoryMapsFilters(t *testing.T) {
|
|
caller, err := runAitableWorkflowCommand(t, nil,
|
|
"history",
|
|
"--base-id", "base-history",
|
|
"--workflow-id", "workflow-history",
|
|
"--status", "failed",
|
|
"--after-time", "1786000000000",
|
|
"--before-time", "1787000000000",
|
|
"--page", "2",
|
|
"--size", "50",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("workflow history returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
|
}
|
|
call := caller.calls[0]
|
|
if call.productID != "aitable" || call.toolName != "get_flow_record_list" {
|
|
t.Fatalf("tool call = %s/%s, want aitable/get_flow_record_list", call.productID, call.toolName)
|
|
}
|
|
wantArgs := map[string]any{
|
|
"baseId": "base-history",
|
|
"flowId": "workflow-history",
|
|
"status": "failed",
|
|
"afterTime": 1786000000000,
|
|
"beforeTime": 1787000000000,
|
|
"page": 2,
|
|
"size": 50,
|
|
}
|
|
if !reflect.DeepEqual(call.args, wantArgs) {
|
|
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableWorkflowHistoryMapsSingleTimeFilter(t *testing.T) {
|
|
caller, err := runAitableWorkflowCommand(t, nil,
|
|
"history",
|
|
"--base-id", "base-history",
|
|
"--workflow-id", "workflow-history",
|
|
"--after-time", "1786000000000",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("workflow history returned error: %v", err)
|
|
}
|
|
wantArgs := map[string]any{
|
|
"baseId": "base-history",
|
|
"flowId": "workflow-history",
|
|
"afterTime": 1786000000000,
|
|
"size": 20,
|
|
}
|
|
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
|
t.Fatalf("calls = %#v, want one history invocation %#v", caller.calls, wantArgs)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAitableWorkflowHistoryRejectsInvalidFilters(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{name: "status", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--status", "unknown"}, want: "允许值"},
|
|
{name: "negative page", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--page", "-1"}, want: "--page 必须 >= 0"},
|
|
{name: "zero size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "0"}, want: "--size 必须在"},
|
|
{name: "large size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "101"}, want: "--size 必须在"},
|
|
{name: "negative after", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "-1"}, want: "Unix 毫秒时间戳"},
|
|
{name: "reversed range", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "200", "--before-time", "100"}, want: "必须小于"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("error = %v, want substring %q", err, tc.want)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("invalid history query reached MCP: %#v", caller.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|