Files
larksuite__cli/shortcuts/base/workflow_execute_test.go
bytedance-zhangbinkai ac0f243e5b feat(base): support AI classification and AI Analysis Action (#2590)
* docs(base): sync workflow guide to current branch

* docs(base): sync workflow schema to current branch

* feat(base): support AI classification workflow validation

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* docs(base): document AI classification workflow schema

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* feat(base): validate AI classification agent data

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix(base): relax ai classification optional fields

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix(base): validate workflow ai analysis json

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix(base): validate workflow ai analysis json

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix(base): default ai classification no match action

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix(base): keep ai analysis validation scoped

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix(base): normalize workflow empty steps

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix(base): reject ai classification mode input

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix: polish skill

* fix: 还原 step 判断逻辑

* fix: 调整校验逻辑组织形式

* fix: polish skill

* fix: CR Comment

* feat: support development environment overrides

* fix: CR Comment

* Revert "feat: support development environment overrides"

This reverts commit cef8f78dc6.

* fix: polish skill

* fix: compress skill

* feat: support development environment overrides

* Revert "feat: support development environment overrides"

This reverts commit cef8f78dc6.

* fix: polish skill

* feat: support development environment overrides

* fix(base): preserve omitted AI classification strategy

* Revert "feat: support development environment overrides"

This reverts commit cef8f78dc6.

* fix: polish skill

* fix: ut

* feat: support development environment overrides

* fix: 沿用全量更新逻辑

* Revert "feat: support development environment overrides"

This reverts commit f805081e89.

---------

Co-authored-by: TRAE CLI <traecli@bytedance.com>
2026-09-03 19:34:14 +08:00

852 lines
34 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"encoding/json"
"errors"
"net/http"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBaseButtonRuleScopesDoNotRequireWorkflowAccess(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantScopes []string
}{
{name: "bind", shortcut: BaseButtonRuleBind, wantScopes: []string{"base:field:read", "base:field:update"}},
{name: "get", shortcut: BaseButtonRuleGet, wantScopes: []string{"base:field:read"}},
{name: "unbind", shortcut: BaseButtonRuleUnbind, wantScopes: []string{"base:field:read", "base:field:update"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !slices.Equal(tt.shortcut.Scopes, tt.wantScopes) {
t.Fatalf("Scopes=%v want=%v", tt.shortcut.Scopes, tt.wantScopes)
}
})
}
}
func TestBaseWorkflowExecuteGet(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"workflow_id": "wkf_1", "title": "My Workflow"},
},
})
if err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--base-token", "app_x", "--workflow-id", "wkf_1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"wkf_1"`) || !strings.Contains(got, `"My Workflow"`) {
t.Fatalf("stdout=%s", got)
}
}
func TestBaseWorkflowExecuteGetWithUserIDType(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "user_id_type=open_id",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"workflow_id": "wkf_1", "creator": map[string]interface{}{"open_id": "ou_abc"}},
},
})
if err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--base-token", "app_x", "--workflow-id", "wkf_1", "--user-id-type", "open_id"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"ou_abc"`) {
t.Fatalf("stdout=%s", got)
}
}
func TestBaseWorkflowExecuteGetValidate(t *testing.T) {
t.Run("missing base-token", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--workflow-id", "wkf_1"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "base-token") {
t.Fatalf("err=%v", err)
}
})
t.Run("missing workflow-id", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--base-token", "app_x"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "workflow-id") {
t.Fatalf("err=%v", err)
}
})
}
func TestBaseWorkflowExecuteCreate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/workflows",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"workflow_id": "wkf_new", "title": "My Workflow"},
},
})
if err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"title":"My Workflow","steps":[]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"wkf_new"`) {
t.Fatalf("stdout=%s", got)
}
}
func TestBaseWorkflowExecuteCreatePreservesAIClassificationAgentData(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/workflows",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"workflow_id": "wkf_ai", "title": "Feedback classify"},
},
}
reg.Register(stub)
body := `{
"title": "Feedback classify",
"steps": [
{"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {"table_name": "Feedback"}},
{
"id": "step_classify",
"type": "AIClassificationBranch",
"children": {"links": [
{"kind": "case", "label": "branch_1", "desc": "Bug", "to": "step_bug"},
{"kind": "case", "label": "branch_2", "desc": "Feature", "to": "step_feature"},
{"kind": "case", "label": "default", "desc": "默认分支", "to": "step_other"}
]},
"data": {
"classes": [
{"name": "Bug", "desc": "Broken behavior"},
{"name": "Feature", "desc": "New capability"}
],
"content": [
{"value_type": "text", "value": "Classify feedback: "},
{"value_type": "ref", "value": "$.step_trigger.fldFeedback"}
],
"classification_rule": "Use Other when unsure.",
"no_match_action": "classifyToOther",
"future_server_field": {"keep": true}
}
},
{"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}}
]
}`
if err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", body}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := string(stub.CapturedBody); !strings.Contains(got, `"type":"AIClassificationBranch"`) || !strings.Contains(got, `"future_server_field":{"keep":true}`) {
t.Fatalf("AI classification payload was not forwarded verbatim enough: %s", got)
}
}
func TestBaseWorkflowExecuteCreateValidate(t *testing.T) {
t.Run("missing base-token", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--json", `{"title":"x"}`}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "base-token") {
t.Fatalf("err=%v", err)
}
})
t.Run("invalid json", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `not-json`}, factory, stdout)
if err == nil {
t.Fatalf("expected error for invalid json")
}
})
}
func TestBaseWorkflowExecuteValidateAIClassificationAgentData(t *testing.T) {
base := func(data string, children string) string {
return `{
"title": "Feedback classify",
"steps": [
{"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}},
{"id": "step_classify", "type": "AIClassificationBranch", "children": ` + children + `, "data": ` + data + `},
{"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}}
]
}`
}
validChildren := `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"}]}`
validData := `{
"classes": [
{"name": "Bug", "desc": "Broken behavior"},
{"name": "Feature", "desc": "New capability"}
],
"content": [{"value_type": "text", "value": "Classify"}],
"classification_rule": "Use the closest category.",
"no_match_action": "fail"
}`
tests := []struct {
name string
body string
want string
}{
{
name: "draft data is not public protocol",
body: base(`{"prompt":[{"value_type":"text","value":"Classify"}],"childBranchList":[{"name":"Bug"},{"name":"Feature"}],"no_match_action":"fail"}`, validChildren),
want: "data.classes must be an array",
},
{
name: "exclusive mode is not public input",
body: base(strings.Replace(validData, `"classes": [`, `"mode": "Exclusive", "classes": [`, 1), validChildren),
want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode",
},
{
name: "parallel mode is not public input",
body: base(strings.Replace(validData, `"classes": [`, `"mode": "Parallel", "classes": [`, 1), validChildren),
want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode",
},
{
name: "empty mode is not public input",
body: base(strings.Replace(validData, `"classes": [`, `"mode": "", "classes": [`, 1), validChildren),
want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode",
},
{
name: "non string mode is not public input",
body: base(strings.Replace(validData, `"classes": [`, `"mode": true, "classes": [`, 1), validChildren),
want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode",
},
{
name: "empty links",
body: base(validData, `{"links":[]}`),
want: "children.links must contain one non-empty case link for each class",
},
{
name: "other default label",
body: base(strings.Replace(validData, `"no_match_action": "fail"`, `"no_match_action": "classifyToOther"`, 1), `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"},{"kind":"case","label":"other","desc":"其他","to":"step_other"}]}`),
want: "label must be default",
},
{
name: "missing no match action still requires default link",
body: base(strings.Replace(validData, `,
"no_match_action": "fail"`, "", 1), validChildren),
want: "children.links must contain exactly one default link when no_match_action is classifyToOther",
},
{
name: "class link count mismatch",
body: base(validData, `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"}]}`),
want: "children.links must contain one non-empty case link for each class",
},
{
name: "class link desc mismatch",
body: base(validData, `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Mismatch","to":"step_feature"}]}`),
want: "desc must equal --json steps data.classes[1].name",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", tt.body}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("err=%v want substring %q", err, tt.want)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("err type=%T want *errs.ValidationError", err)
}
})
}
}
func TestBaseWorkflowExecuteValidateAIClassificationOptionalModeAndNoMatchAction(t *testing.T) {
base := func(data string, children string) string {
return `{
"title": "Feedback classify",
"steps": [
{"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}},
{"id": "step_classify", "type": "AIClassificationBranch", "children": ` + children + `, "data": ` + data + `},
{"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}}
]
}`
}
data := `{
"classes": [
{"name": "Bug", "desc": "Broken behavior"},
{"name": "Feature", "desc": "New capability"}
],
"content": [{"value_type": "text", "value": "Classify"}],
"classification_rule": "Use the closest category."
}`
children := `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"},{"kind":"case","label":"default","desc":"默认分支","to":"step_other"}]}`
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/workflows",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"workflow_id": "wkf_ai", "title": "Feedback classify"},
},
}
reg.Register(stub)
if err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", base(data, children)}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := string(stub.CapturedBody)
if strings.Contains(got, `"mode"`) || strings.Contains(got, `"no_match_action"`) {
t.Fatalf("AI classification optional fields should not be injected by CLI: %s", got)
}
}
func TestBaseWorkflowExecuteUpdateRejectsAIClassificationMode(t *testing.T) {
base := func(mode string) string {
return `{
"title": "Feedback classify",
"steps": [
{"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}},
{
"id": "step_classify",
"type": "AIClassificationBranch",
"children": {"links":[
{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},
{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"}
]},
"data": {
"mode": "` + mode + `",
"classes": [
{"name": "Bug", "desc": "Broken behavior"},
{"name": "Feature", "desc": "New capability"}
],
"content": [{"value_type": "text", "value": "Classify"}],
"classification_rule": "Use the closest category.",
"no_match_action": "fail"
}
},
{"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}}
]
}`
}
for _, mode := range []string{"Exclusive", "Parallel"} {
t.Run(mode, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", base(mode)}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "data.mode is not supported; omit it because AI classification only supports Exclusive mode") {
t.Fatalf("err=%v", err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("err type=%T want *errs.ValidationError", err)
}
})
}
}
func TestBaseWorkflowExecuteUpdatePreservesAIClassificationWithoutMode(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"workflow_id": "wkf_1", "title": "Feedback classify"},
},
}
reg.Register(stub)
body := `{
"title": "Feedback classify",
"status": "disabled",
"steps": [
{"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}},
{
"id": "step_classify",
"type": "AIClassificationBranch",
"children": {"links":[
{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},
{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"},
{"kind":"case","label":"default","desc":"默认分支","to":"step_other"}
]},
"data": {
"classes": [
{"name": "Bug", "desc": "Broken behavior"},
{"name": "Feature", "desc": "New capability"}
],
"content": [{"value_type": "text", "value": "Classify"}],
"classification_rule": "Use the closest category."
}
},
{"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}}
]
}`
if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", body}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := string(stub.CapturedBody); strings.Contains(got, `"mode"`) || strings.Contains(got, `"no_match_action"`) || !strings.Contains(got, `"classes":[`) {
t.Fatalf("AI classification payload should be forwarded without injected optional fields: %s", got)
}
}
func TestBaseWorkflowExecuteUpdateValidatesAIClassificationNoMatchActionTopology(t *testing.T) {
body := func(noMatchAction string, links string) string {
return `{
"title": "Language classify",
"steps": [
{"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}},
{
"id": "step_classify",
"type": "AIClassificationBranch",
"children": {"links":` + links + `},
"data": {
"classes": [
{"name": "English", "desc": "English text"},
{"name": "Chinese", "desc": "Chinese text"}
],
"content": [{"value_type": "text", "value": "Classify"}]` + noMatchAction + `
}
},
{"id": "step_english", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_chinese", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_other", "type": "SetRecordAction", "next": null, "data": {}},
{"id": "step_other_2", "type": "SetRecordAction", "next": null, "data": {}}
]
}`
}
caseLinks := `[
{"kind":"case","label":"branch_1","desc":"English","to":"step_english"},
{"kind":"case","label":"branch_2","desc":"Chinese","to":"step_chinese"}
]`
defaultLinks := `[
{"kind":"case","label":"branch_1","desc":"English","to":"step_english"},
{"kind":"case","label":"branch_2","desc":"Chinese","to":"step_chinese"},
{"kind":"case","label":"default","desc":"Other","to":"step_other"}
]`
multipleDefaultLinks := `[
{"kind":"case","label":"branch_1","desc":"English","to":"step_english"},
{"kind":"case","label":"branch_2","desc":"Chinese","to":"step_chinese"},
{"kind":"case","label":"default","desc":"Other","to":"step_other"},
{"kind":"case","label":"default","desc":"Other 2","to":"step_other_2"}
]`
tests := []struct {
name string
noMatchAction string
links string
want string
wantForwarded string
}{
{
name: "rejects omitted action without default link",
links: caseLinks,
want: "children.links must contain exactly one default link when no_match_action is classifyToOther",
},
{
name: "accepts omitted action with one default link without injection",
links: defaultLinks,
wantForwarded: `"label":"default"`,
},
{
name: "accepts explicit fail without default link",
noMatchAction: `,"no_match_action":"fail"`,
links: caseLinks,
wantForwarded: `"no_match_action":"fail"`,
},
{
name: "rejects explicit fail with default link",
noMatchAction: `,"no_match_action":"fail"`,
links: defaultLinks,
want: "children.links must not contain a default link when no_match_action is fail",
},
{
name: "accepts explicit classifyToOther with one default link",
noMatchAction: `,"no_match_action":"classifyToOther"`,
links: defaultLinks,
wantForwarded: `"no_match_action":"classifyToOther"`,
},
{
name: "rejects explicit classifyToOther without default link",
noMatchAction: `,"no_match_action":"classifyToOther"`,
links: caseLinks,
want: "children.links must contain exactly one default link when no_match_action is classifyToOther",
},
{
name: "rejects omitted action with multiple default links",
links: multipleDefaultLinks,
want: "children.links must contain exactly one default link when no_match_action is classifyToOther",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowUpdate, []string{
"+workflow-update",
"--base-token", "app_x",
"--workflow-id", "wkf_1",
"--json", body(tt.noMatchAction, tt.links),
"--dry-run",
"--format", "pretty",
}, factory, stdout)
if tt.want != "" {
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("err=%v want substring %q", err, tt.want)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("err type=%T want *errs.ValidationError", err)
}
return
}
if err != nil {
t.Fatalf("dry-run err=%v", err)
}
dryRun := stdout.String()
if !strings.Contains(dryRun, "PUT /open-apis/base/v3/bases/app_x/workflows/wkf_1") || !strings.Contains(dryRun, tt.wantForwarded) {
t.Fatalf("dry-run did not preserve the validated request: %s", dryRun)
}
if tt.noMatchAction == "" && strings.Contains(dryRun, `"no_match_action"`) {
t.Fatalf("dry-run must not inject omitted no_match_action: %s", dryRun)
}
})
}
}
func TestBaseWorkflowExecuteCreateValidateAIAnalysisData(t *testing.T) {
t.Run("rejects table names string", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":"订单表","identity_type":"maker"}}]}`}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "steps[0].data.analysis_table_names")
})
t.Run("rejects table names item type", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表",1],"identity_type":"maker"}}]}`}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "steps[0].data.analysis_table_names[1]")
})
t.Run("rejects identity type enum", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"unknownIdentity"}}]}`}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maker, triggerPersonal")
if !strings.Contains(err.Error(), "steps[0].data.identity_type") {
t.Fatalf("err=%v, want field path", err)
}
})
t.Run("accepts maker", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/workflows",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"workflow_id": "wkf_new"},
},
})
err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"maker"}}]}`}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
})
t.Run("accepts triggerPersonal", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/workflows",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"workflow_id": "wkf_new"},
},
})
err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"triggerPersonal"}}]}`}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
})
}
func TestBaseWorkflowExecuteUpdateValidateAIAnalysisData(t *testing.T) {
t.Run("rejects table names string", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":"订单表","identity_type":"maker"}}]}`}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "steps[0].data.analysis_table_names")
})
t.Run("rejects identity type enum", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"unknownIdentity"}}]}`}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maker, triggerPersonal")
if !strings.Contains(err.Error(), "steps[0].data.identity_type") {
t.Fatalf("err=%v, want field path", err)
}
})
}
func TestBaseWorkflowExecuteUpdatePreservesOmittedSteps(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"workflow_id": "wkf_1", "title": "Only Title"},
},
}
reg.Register(stub)
if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", `{"title":"Only Title"}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("request body invalid JSON: %v", err)
}
if steps, ok := body["steps"]; ok {
t.Fatalf("request steps=%#v, want field omitted", steps)
}
}
func TestBaseWorkflowDryRunUpdatePreservesOmittedSteps(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+workflow-update",
"--base-token", "app_x",
"--workflow-id", "wkf_1",
"--json", `{"title":"Only Title"}`,
"--dry-run",
"--format", "pretty",
}
if err := runShortcut(t, BaseWorkflowUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "PUT /open-apis/base/v3/bases/app_x/workflows/wkf_1") || !strings.Contains(got, `"title":"Only Title"`) {
t.Fatalf("stdout=%s", got)
}
if strings.Contains(got, `"steps":`) {
t.Fatalf("dry-run injected omitted steps: %s", got)
}
}
func TestBaseWorkflowExecuteDisable(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1/disable",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"workflow_id": "wkf_1", "status": "disabled"},
},
})
if err := runShortcut(t, BaseWorkflowDisable, []string{"+workflow-disable", "--base-token", "app_x", "--workflow-id", "wkf_1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"disabled"`) {
t.Fatalf("stdout=%s", got)
}
}
func TestBaseWorkflowExecuteDisableValidate(t *testing.T) {
t.Run("missing base-token", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowDisable, []string{"+workflow-disable", "--workflow-id", "wkf_1"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "base-token") {
t.Fatalf("err=%v", err)
}
})
t.Run("missing workflow-id", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseWorkflowDisable, []string{"+workflow-disable", "--base-token", "app_x"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "workflow-id") {
t.Fatalf("err=%v", err)
}
})
}
func TestBaseButtonRuleExecuteResolvesFieldReference(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
args []string
fieldRef string
canonicalFieldID string
fieldIdentityKey string
buttonRuleMethod string
wantWorkflowID string
wantWorkflowField bool
}{
{
name: "bind by name", shortcut: BaseButtonRuleBind,
args: []string{"+button-rule-bind", "--base-token", "app_x", "--table-id", "tbl_1", "--field-id", "按钮", "--workflow-id", "wkf_1"},
fieldRef: "按钮", canonicalFieldID: "fld_bind", fieldIdentityKey: "id",
buttonRuleMethod: "PUT", wantWorkflowID: "wkf_1", wantWorkflowField: true,
},
{
name: "get by name", shortcut: BaseButtonRuleGet,
args: []string{"+button-rule-get", "--base-token", "app_x", "--table-id", "tbl_1", "--field-id", "按钮"},
fieldRef: "按钮", canonicalFieldID: "fld_get", fieldIdentityKey: "id",
buttonRuleMethod: "GET",
},
{
name: "unbind by name with field_id compatibility", shortcut: BaseButtonRuleUnbind,
args: []string{"+button-rule-unbind", "--base-token", "app_x", "--table-id", "tbl_1", "--field-id", "按钮"},
fieldRef: "按钮", canonicalFieldID: "fld_unbind", fieldIdentityKey: "field_id",
buttonRuleMethod: "PUT", wantWorkflowID: "", wantWorkflowField: true,
},
{
name: "ID input is still resolved", shortcut: BaseButtonRuleGet,
args: []string{"+button-rule-get", "--base-token", "app_x", "--table-id", "tbl_1", "--field-id", "fld_input"},
fieldRef: "fld_input", canonicalFieldID: "fld_canonical", fieldIdentityKey: "id",
buttonRuleMethod: "GET",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
callOrder := 0
reg.Register(&httpmock.Stub{
Method: "GET",
URL: baseV3Path("bases", "app_x", "tables", "tbl_1", "fields", tt.fieldRef),
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{tt.fieldIdentityKey: tt.canonicalFieldID, "name": tt.fieldRef},
},
OnMatch: func(_ *http.Request) {
if callOrder != 0 {
t.Fatalf("field resolution call order=%d want=0", callOrder)
}
callOrder++
},
})
buttonRuleStub := &httpmock.Stub{
Method: tt.buttonRuleMethod,
URL: baseV3Path("bases", "app_x", "tables", "tbl_1", "fields", tt.canonicalFieldID, "button_rule"),
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"table_id": "tbl_1", "field_id": tt.canonicalFieldID, "workflow_id": tt.wantWorkflowID, "bound": tt.wantWorkflowID != ""},
},
OnMatch: func(_ *http.Request) {
if callOrder != 1 {
t.Fatalf("ButtonRule call order=%d want=1", callOrder)
}
callOrder++
},
}
reg.Register(buttonRuleStub)
if err := runShortcut(t, tt.shortcut, tt.args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if callOrder != 2 {
t.Fatalf("call order count=%d want=2", callOrder)
}
if got := stdout.String(); !strings.Contains(got, `"field_id": "`+tt.canonicalFieldID+`"`) {
t.Fatalf("stdout=%s", got)
}
if tt.wantWorkflowField {
var body map[string]interface{}
if err := json.Unmarshal(buttonRuleStub.CapturedBody, &body); err != nil {
t.Fatalf("decode ButtonRule body: %v", err)
}
if got, ok := body["workflow_id"].(string); !ok || got != tt.wantWorkflowID {
t.Fatalf("workflow_id=%#v want=%q body=%s", body["workflow_id"], tt.wantWorkflowID, buttonRuleStub.CapturedBody)
}
}
})
}
}
func TestBaseButtonRuleFieldResolutionFailureStopsBeforeButtonRule(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
buttonRuleCalls := 0
reg.Register(&httpmock.Stub{
Method: "GET",
URL: baseV3Path("bases", "app_x", "tables", "tbl_1", "fields", "missing"),
Body: map[string]interface{}{
"code": 1254045,
"msg": "field not found",
"data": map[string]interface{}{"error": map[string]interface{}{"logid": "log_field_resolution"}},
},
})
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/button_rule", Optional: true,
OnMatch: func(_ *http.Request) { buttonRuleCalls++ },
})
err := runShortcut(t, BaseButtonRuleBind, []string{"+button-rule-bind", "--base-token", "app_x", "--table-id", "tbl_1", "--field-id", "missing", "--workflow-id", "wkf_1"}, factory, stdout)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryAPI || problem.Code != 1254045 || problem.LogID != "log_field_resolution" {
t.Fatalf("expected preserved typed field resolution error, got %T %#v", err, problem)
}
if buttonRuleCalls != 0 {
t.Fatalf("ButtonRule calls=%d want=0", buttonRuleCalls)
}
}
func TestBaseButtonRuleFieldResolutionRejectsMissingCanonicalID(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
buttonRuleCalls := 0
reg.Register(&httpmock.Stub{
Method: "GET",
URL: baseV3Path("bases", "app_x", "tables", "tbl_1", "fields", "按钮"),
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "按钮"}},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/button_rule", Optional: true,
OnMatch: func(_ *http.Request) { buttonRuleCalls++ },
})
err := runShortcut(t, BaseButtonRuleGet, []string{"+button-rule-get", "--base-token", "app_x", "--table-id", "tbl_1", "--field-id", "按钮"}, factory, stdout)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("expected typed invalid-response error, got %T %#v", err, problem)
}
if buttonRuleCalls != 0 {
t.Fatalf("ButtonRule calls=%d want=0", buttonRuleCalls)
}
}
func TestBaseButtonRuleAPIFailurePreservesTypedCause(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
cause := errors.New("button rule transport failed")
reg.Register(&httpmock.Stub{
Method: "GET",
URL: baseV3Path("bases", "app_x", "tables", "tbl_1", "fields", "按钮"),
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"id": "fld_1"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: baseV3Path("bases", "app_x", "tables", "tbl_1", "fields", "fld_1", "button_rule"),
Error: cause,
})
err := runShortcut(t, BaseButtonRuleGet, []string{"+button-rule-get", "--base-token", "app_x", "--table-id", "tbl_1", "--field-id", "按钮"}, factory, stdout)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || !errors.Is(err, cause) {
t.Fatalf("expected typed network error preserving cause, got %T %#v", err, problem)
}
}
func TestBaseButtonRuleValidateRejectsInternalWorkflowID(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseButtonRuleBind, []string{"+button-rule-bind", "--base-token", "app_x", "--table-id", "tbl_1", "--field-id", "fld_1", "--workflow-id", "123456"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "public wkf workflow ID") {
t.Fatalf("err=%v", err)
}
}