Files

1741 lines
60 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package helpers
import (
"bytes"
"context"
"encoding/json"
stderrors "errors"
"io"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
func newDevAppTestRoot(runner executor.Runner) *cobra.Command {
ctx, _ := output.WithResultStore(context.Background())
root := &cobra.Command{
Use: "dws",
DisableAutoGenTag: true,
SilenceErrors: true,
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
return output.ValidateUnifiedFormat(cmd)
},
PersistentPostRunE: func(cmd *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(cmd)
return err
},
}
root.SetContext(ctx)
root.PersistentFlags().Bool("dry-run", false, "dry run")
root.PersistentFlags().Bool("yes", false, "yes")
root.PersistentFlags().String("format", "json", "format")
root.AddCommand(devHandler{}.Command(runner))
return root
}
func prepareUnifiedTestCommand(cmd *cobra.Command) *cobra.Command {
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
if cmd.Flags().Lookup("format") == nil {
cmd.Flags().String("format", "json", "format")
}
if cmd.Flags().Lookup("dry-run") == nil {
cmd.Flags().Bool("dry-run", false, "dry run")
}
if cmd.Flags().Lookup("yes") == nil {
cmd.Flags().Bool("yes", false, "yes")
}
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
return cmd
}
type devAppResponseRunner struct {
last executor.Invocation
response map[string]any
preserveMissingPagination bool
}
func (r *devAppResponseRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.last = invocation
invocation.Implemented = true
response := r.response
if !r.preserveMissingPagination && devAppToolRequiresPagination(invocation.Tool) {
if service, ok := response["content"].(map[string]any); ok {
if result, ok := service["result"].(map[string]any); ok {
_, hasFlag := result["hasMore"]
_, hasCursor := result["nextCursor"]
if !hasFlag && !hasCursor {
resultCopy := make(map[string]any, len(result)+1)
for key, value := range result {
resultCopy[key] = value
}
resultCopy["hasMore"] = false
serviceCopy := make(map[string]any, len(service))
for key, value := range service {
serviceCopy[key] = value
}
serviceCopy["result"] = resultCopy
responseCopy := make(map[string]any, len(response))
for key, value := range response {
responseCopy[key] = value
}
responseCopy["content"] = serviceCopy
response = responseCopy
}
}
}
}
return executor.Result{Invocation: invocation, Response: response}, nil
}
func TestDevAppMemberCommandsBuildToolParams(t *testing.T) {
cases := []struct {
name string
cmd string
args []string
wantTool string
wantParams map[string]any
}{
{
name: "list",
cmd: "list",
args: []string{"--unified-app-id", "app-001"},
wantTool: "list_dev_app_members",
wantParams: map[string]any{
"unifiedAppId": "app-001",
},
},
{
name: "add multiple users",
cmd: "add",
args: []string{"--unified-app-id", "app-001", "--user-ids", "userId1,userId2,userId3,userId4", "--member-type", "DEVELOPER", "--yes"},
wantTool: "add_dev_app_members",
wantParams: map[string]any{
"unifiedAppId": "app-001",
"userIds": []string{"userId1", "userId2", "userId3", "userId4"},
"memberType": "DEVELOPER",
},
},
{
name: "remove trims users",
cmd: "remove",
args: []string{"--unified-app-id", "app-001", "--user-ids", " userId1 , userId2 ", "--member-type", "DEVELOPER", "--yes"},
wantTool: "remove_dev_app_members",
wantParams: map[string]any{
"unifiedAppId": "app-001",
"userIds": []string{"userId1", "userId2"},
"memberType": "DEVELOPER",
},
},
{
name: "add accepts legacy member user ids flag",
cmd: "add",
args: []string{"--unified-app-id", "app-001", "--member-user-ids", "userId1,userId2", "--member-type", "DEVELOPER", "--yes"},
wantTool: "add_dev_app_members",
wantParams: map[string]any{
"unifiedAppId": "app-001",
"userIds": []string{"userId1", "userId2"},
"memberType": "DEVELOPER",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(append([]string{"dev", "app", "member", tc.cmd}, tc.args...))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.CanonicalProduct; got != "devapp" {
t.Fatalf("CanonicalProduct = %q, want devapp", got)
}
if got := runner.last.Tool; got != tc.wantTool {
t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
}
if !reflect.DeepEqual(runner.last.Params, tc.wantParams) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams)
}
})
}
}
func TestDevCommandTree(t *testing.T) {
dev := devHandler{}.Command(&captureRunner{})
if dev.Name() != "dev" {
t.Fatalf("Name() = %q, want dev", dev.Name())
}
if len(dev.Aliases) != 0 {
t.Fatalf("Aliases = %v, want none (clean switch from devapp)", dev.Aliases)
}
// 三支柱:app / connect / doc
for _, name := range []string{"app", "connect", "doc"} {
if _, _, err := dev.Find([]string{name}); err != nil {
t.Fatalf("missing subtree %q: %v", name, err)
}
}
app, _, err := dev.Find([]string{"app"})
if err != nil {
t.Fatalf("find app: %v", err)
}
for _, name := range []string{"list", "get", "create", "update", "delete", "disable", "enable", "credentials", "webapp", "permission", "member", "security", "robot", "version", "event"} {
if _, _, err := app.Find([]string{name}); err != nil {
t.Fatalf("missing app command %q: %v", name, err)
}
}
// connect 已从 robot 子树提升为 dev connect,robot 下不应再有
robot, _, err := app.Find([]string{"robot"})
if err != nil {
t.Fatalf("find robot: %v", err)
}
for _, sub := range robot.Commands() {
if sub.Name() == "connect" {
t.Fatalf("robot subtree still has connect; it moved to dev connect")
}
}
}
func TestDevAppRobotCommandsBuildToolParams(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantParams map[string]any
}{
{
name: "submit async fills media placeholders",
args: []string{"robot", "submit", "--name", "智能体", "--robot-name", "小助手", "--desc", "审批问答", "--task-id", "t-1", "--yes"},
wantTool: "submit_robot_create_task",
wantParams: map[string]any{
"name": "智能体",
"robotName": "小助手",
"desc": "审批问答",
"iconMediaId": "",
"previewMediaId": "",
"taskId": "t-1",
},
},
{
name: "result",
args: []string{"robot", "result", "--task-id", "t-1"},
wantTool: "query_robot_create_result",
wantParams: map[string]any{"taskId": "t-1"},
},
{
name: "get config",
args: []string{"robot", "get", "--unified-app-id", "u-1"},
wantTool: "get_extension_robot_config",
wantParams: map[string]any{"unifiedAppId": "u-1"},
},
{
name: "config create with skills and mode",
args: []string{"robot", "config", "--unified-app-id", "u-1", "--name", "小助手", "--brief", "审批助手", "--mode", "STREAM", "--skills", "qa,approval", "--add-scope", "--yes"},
wantTool: "set_extension_robot_config",
wantParams: map[string]any{
"unifiedAppId": "u-1",
"name": "小助手",
"brief": "审批助手",
"mode": "STREAM",
"skills": []string{"qa", "approval"},
"addScope": true,
},
},
{
name: "robot disable",
args: []string{"robot", "disable", "--unified-app-id", "u-1", "--yes"},
wantTool: "disable_dev_app_robot",
wantParams: map[string]any{"unifiedAppId": "u-1"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(append([]string{"dev", "app"}, tc.args...))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.CanonicalProduct; got != "devapp" {
t.Fatalf("CanonicalProduct = %q, want devapp", got)
}
if got := runner.last.Tool; got != tc.wantTool {
t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
}
if !reflect.DeepEqual(runner.last.Params, tc.wantParams) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams)
}
})
}
}
func TestDevAppVersionCommandsBuildToolParams(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantParams map[string]any
}{
{
name: "create lets server auto-increment version",
args: []string{"version", "create", "--unified-app-id", "u-1", "--desc", "新增机器人", "--yes"},
wantTool: "create_dev_app_version",
wantParams: map[string]any{
"unifiedAppId": "u-1",
"desc": "新增机器人",
},
},
{
name: "create with explicit version",
args: []string{"version", "create", "--unified-app-id", "u-1", "--version", "1.0.1", "--desc", "新增机器人", "--yes"},
wantTool: "create_dev_app_version",
wantParams: map[string]any{
"unifiedAppId": "u-1",
"version": "1.0.1",
"desc": "新增机器人",
},
},
{
name: "list",
args: []string{"version", "list", "--unified-app-id", "u-1", "--cursor", "tok-1", "--page-size", "5"},
wantTool: "list_dev_app_versions",
wantParams: map[string]any{
"unifiedAppId": "u-1",
"cursor": "tok-1",
"pageSize": 5,
},
},
{
name: "get detail",
args: []string{"version", "get", "--unified-app-id", "u-1", "--version-id", "v-1"},
wantTool: "get_dev_app_version_detail",
wantParams: map[string]any{"unifiedAppId": "u-1", "versionId": "v-1"},
},
{
name: "check-approval forces precheckOnly",
args: []string{"version", "check-approval", "--unified-app-id", "u-1", "--version-id", "v-1"},
wantTool: "publish_dev_app_version",
wantParams: map[string]any{"unifiedAppId": "u-1", "versionId": "v-1", "precheckOnly": true},
},
{
name: "publish sets precheckOnly false and sensitive",
args: []string{"version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1", "--confirmed-sensitive", "--approver-user-id", "user-1", "--yes"},
wantTool: "publish_dev_app_version",
wantParams: map[string]any{
"unifiedAppId": "u-1",
"versionId": "v-1",
"precheckOnly": false,
"confirmedSensitive": true,
"approverUserId": "user-1",
},
},
{
name: "status",
args: []string{"version", "status", "--unified-app-id", "u-1", "--version-id", "v-1"},
wantTool: "get_dev_app_version_status",
wantParams: map[string]any{"unifiedAppId": "u-1", "versionId": "v-1"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(append([]string{"dev", "app"}, tc.args...))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != tc.wantTool {
t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
}
if !reflect.DeepEqual(runner.last.Params, tc.wantParams) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams)
}
})
}
}
func TestDevAppRobotAndVersionWritesRequireGuard(t *testing.T) {
cases := []struct {
name string
args []string
}{
{name: "robot config", args: []string{"dev", "app", "robot", "config", "--unified-app-id", "u-1", "--name", "小助手"}},
{name: "robot disable", args: []string{"dev", "app", "robot", "disable", "--unified-app-id", "u-1"}},
{name: "version create", args: []string{"dev", "app", "version", "create", "--unified-app-id", "u-1"}},
{name: "version publish", args: []string{"dev", "app", "version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatal("Execute() error = nil, want write guard")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("error = %q, want write guard", err.Error())
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
}
})
}
}
func TestDevAppListBuildsListByConditionParams(t *testing.T) {
runner := &captureRunner{}
root := prepareUnifiedTestCommand(newDevAppCommand(runner))
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"list", "--name", "Waker", "--cursor", "tok-2", "--page-size", "5", "--sort-type", "gmt_modified", "--sort-order", "desc"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != "list_dev_app" {
t.Fatalf("Tool = %q, want list_dev_app", got)
}
want := map[string]any{
"cursor": "tok-2",
"pageSize": 5,
"name": "Waker",
"sortType": "gmt_modified",
"sortOrder": "desc",
}
if !reflect.DeepEqual(runner.last.Params, want) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, want)
}
}
func TestCrossPlatformCoverageDevAppGetBuildsDetailParams(t *testing.T) {
runner := &captureRunner{}
root := prepareUnifiedTestCommand(newDevAppCommand(runner))
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"get", "--unified-app-id", "u-1"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != "get_dev_app" {
t.Fatalf("Tool = %q, want get_dev_app", got)
}
want := map[string]any{"unifiedAppId": "u-1"}
if !reflect.DeepEqual(runner.last.Params, want) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, want)
}
}
func TestCrossPlatformCoverageDevAppGetBuildsDetailParamsByAppKey(t *testing.T) {
runner := &captureRunner{}
root := prepareUnifiedTestCommand(newDevAppCommand(runner))
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"get", "--app-key", "dingxxx"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != "get_dev_app" {
t.Fatalf("Tool = %q, want get_dev_app", got)
}
want := map[string]any{"appKey": "dingxxx"}
if !reflect.DeepEqual(runner.last.Params, want) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, want)
}
}
func TestCrossPlatformCoverageDevAppGetPrefersUnifiedAppIDWhenBothPresent(t *testing.T) {
runner := &captureRunner{}
root := prepareUnifiedTestCommand(newDevAppCommand(runner))
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"get", "--unified-app-id", "u-1", "--app-key", "dingxxx"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
want := map[string]any{"unifiedAppId": "u-1", "appKey": "dingxxx"}
if !reflect.DeepEqual(runner.last.Params, want) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, want)
}
}
func TestCrossPlatformCoverageDevAppGetRequiresLocator(t *testing.T) {
runner := &captureRunner{}
root := newDevAppCommand(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"get"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "请传入 --unified-app-id 或 --app-key") {
t.Fatalf("error = %v, want locator validation", err)
}
}
func TestDevAppCreateUsesCurrentInnerToolAndWriteGuard(t *testing.T) {
runner := &captureRunner{}
root := newDevAppCommand(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"create", "--name", "Demo"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("error = %v, want write guard", err)
}
runner = &captureRunner{}
root = newDevAppTestRoot(runner)
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "create", "--name", "Demo", "--desc", "internal app", "--yes"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != "create_dev_app" {
t.Fatalf("Tool = %q, want create_dev_app", got)
}
want := map[string]any{"name": "Demo", "desc": "internal app"}
if !reflect.DeepEqual(runner.last.Params, want) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, want)
}
}
func TestDevAppUpdateUsesCurrentInnerTool(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "update", "--unified-app-id", "u-123", "--desc", "new desc", "--yes"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != "update_dev_app" {
t.Fatalf("Tool = %q, want update_dev_app", got)
}
want := map[string]any{"unifiedAppId": "u-123", "desc": "new desc"}
if !reflect.DeepEqual(runner.last.Params, want) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, want)
}
}
func TestDevAppLifecycleBuildsLocatorParams(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantParams map[string]any
}{
{
name: "inactive by unified app id",
args: []string{"disable", "--unified-app-id", "u-1", "--yes"},
wantTool: "disable_dev_app",
wantParams: map[string]any{"unifiedAppId": "u-1"},
},
{
name: "active by unified id",
args: []string{"enable", "--unified-app-id", "u-2", "--yes"},
wantTool: "enable_dev_app",
wantParams: map[string]any{"unifiedAppId": "u-2"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(append([]string{"dev", "app"}, tc.args...))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != tc.wantTool {
t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
}
if !reflect.DeepEqual(runner.last.Params, tc.wantParams) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams)
}
})
}
}
// TestDevAppDeleteConfirmName covers the danger-tier delete: --confirm-name
// must match the located app's real name, and a name that can't be read is
// fail-closed (abort), never a silent proceed.
func TestDevAppDeleteConfirmName(t *testing.T) {
t.Run("matching name proceeds", func(t *testing.T) {
// get_dev_app 真实返回的应用名字段是 name(不是 appName)——
// fixture 用 name 才能覆盖真实契约,避免 delete 取名回归。
runner := &devAppResponseRunner{response: map[string]any{"name": "DemoApp"}}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "delete", "--unified-app-id", "u-123", "--confirm-name", "DemoApp", "--yes"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if runner.last.Tool != "delete_dev_app" {
t.Fatalf("Tool = %q, want delete_dev_app", runner.last.Tool)
}
})
t.Run("mismatched name aborts", func(t *testing.T) {
runner := &devAppResponseRunner{response: map[string]any{"name": "RealName"}}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "delete", "--unified-app-id", "u-123", "--confirm-name", "WrongName", "--yes"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "名称不匹配") {
t.Fatalf("error = %v, want 名称不匹配", err)
}
if runner.last.Tool == "delete_dev_app" {
t.Fatal("delete must not run on name mismatch")
}
})
t.Run("unreadable name is fail-closed", func(t *testing.T) {
runner := &devAppResponseRunner{response: map[string]any{}} // no appName
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "delete", "--unified-app-id", "u-123", "--confirm-name", "DemoApp", "--yes"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "无法读取应用名") {
t.Fatalf("error = %v, want 无法读取应用名 (fail-closed)", err)
}
if runner.last.Tool == "delete_dev_app" {
t.Fatal("delete must not run when name is unreadable")
}
})
}
// TestDevAppEventSubscribeUsesEventCodes locks the param contract: the server's
// subscribe/unsubscribe tools (plural: subscribe_dev_app_events) read eventCodes
// as an ARRAY — real-device confirmed against the updated MCP schema.
func TestDevAppEventListForwardsKeyword(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "event", "list", "--unified-app-id", "u-1", "--keyword", "通讯录"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if runner.last.Tool != devAppEventListTool {
t.Fatalf("Tool = %q, want %q", runner.last.Tool, devAppEventListTool)
}
if got := runner.last.Params["keyword"]; got != "通讯录" {
t.Fatalf("keyword = %#v, want 通讯录", got)
}
}
func TestDevAppEventSubscribeUsesEventCodes(t *testing.T) {
for _, tc := range []struct {
name string
args []string
wantTool string
}{
{"subscribe", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", "a,b", "--yes"}, "subscribe_dev_app_events"},
{"unsubscribe", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--event-codes", "a,b", "--yes"}, "unsubscribe_dev_app_events"},
} {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if runner.last.Tool != tc.wantTool {
t.Fatalf("Tool = %q, want %q", runner.last.Tool, tc.wantTool)
}
got, ok := runner.last.Params["eventCodes"].([]string)
if !ok || len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("eventCodes = %#v, want []string{a,b}", runner.last.Params["eventCodes"])
}
if _, bad := runner.last.Params["eventCode"]; bad {
t.Fatal("must not send singular eventCode")
}
})
}
}
type devAppFailingCountRunner struct {
calls int
err error
}
func (r *devAppFailingCountRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls++
return executor.Result{Invocation: invocation}, r.err
}
func TestCrossPlatformCoverageDevAppEventSubscribeRunnerFailureIsNotRetried(t *testing.T) {
wantErr := stderrors.New("event subscription failed")
runner := &devAppFailingCountRunner{err: wantErr}
root := newDevAppTestRoot(runner)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{
"dev", "app", "event", "subscribe",
"--unified-app-id", "u-1",
"--event-codes", "a,b",
"--yes",
})
if err := root.Execute(); !stderrors.Is(err, wantErr) {
t.Fatalf("Execute() error = %v, want %v", err, wantErr)
}
if runner.calls != 1 {
t.Fatalf("runner calls = %d, want 1", runner.calls)
}
}
func TestDevAppEventSubscribeRequiresEventCodes(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{"subscribe", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--yes"}},
{"unsubscribe", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--yes"}},
} {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(tc.args)
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "--event-codes 为必填") {
t.Fatalf("Execute() error = %v, want --event-codes 为必填", err)
}
if runner.last.Tool != "" {
t.Fatalf("runner should not be called, got tool %q", runner.last.Tool)
}
})
}
}
func TestCrossPlatformCoverageDevAppEventSubscribeRejectsSeparatorOnlyEventCodes(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{"subscribe_comma", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", ",", "--yes"}},
{"subscribe_semicolon", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", ";", "--yes"}},
{"unsubscribe_comma", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--event-codes", ",", "--yes"}},
} {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(tc.args)
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "--event-codes 为必填") {
t.Fatalf("Execute() error = %v, want --event-codes 为必填", err)
}
if runner.last.Tool != "" {
t.Fatalf("runner should not be called, got tool %q", runner.last.Tool)
}
})
}
}
func TestDevAppWebappCommandsBuildParams(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantParams map[string]any
}{
{
name: "get",
args: []string{"webapp", "get", "--unified-app-id", "u-1"},
wantTool: "get_extension_webapp_config",
wantParams: map[string]any{"unifiedAppId": "u-1"},
},
{
name: "config",
args: []string{"webapp", "config", "--unified-app-id", "u-1", "--homepage-url", "https://example.com", "--pc-homepage-url", "https://pc.example.com", "--yes"},
wantTool: "set_extension_webapp_config",
wantParams: map[string]any{
"unifiedAppId": "u-1",
"homepageUrl": "https://example.com",
"pcHomepageUrl": "https://pc.example.com",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(append([]string{"dev", "app"}, tc.args...))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != tc.wantTool {
t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
}
if !reflect.DeepEqual(runner.last.Params, tc.wantParams) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams)
}
})
}
}
func TestDevAppPermissionCommandsBuildParams(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantParams map[string]any
}{
{
name: "list",
args: []string{"permission", "list", "--unified-app-id", "u-123", "--keyword", "手机号", "--auth-status", "all", "--cursor", "tok-3", "--page-size", "5"},
wantTool: "list_dev_app_permissions",
wantParams: map[string]any{
"unifiedAppId": "u-123",
"keyword": "手机号",
"authStatus": "ALL",
"cursor": "tok-3",
"pageSize": 5,
},
},
{
name: "add",
args: []string{"permission", "add", "--unified-app-id", "u-123", "--scope-values", "Contact.User.mobile,qyapi_robot_sendmsg", "--yes"},
wantTool: "apply_dev_app_permissions",
wantParams: map[string]any{
"unifiedAppId": "u-123",
"scopeValues": []string{"Contact.User.mobile", "qyapi_robot_sendmsg"},
},
},
{
name: "remove",
args: []string{"permission", "remove", "--unified-app-id", "u-123", "--scope-values", "Contact.User.mobile", "--yes"},
wantTool: "remove_dev_app_permissions",
wantParams: map[string]any{
"unifiedAppId": "u-123",
"scopeValues": []string{"Contact.User.mobile"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(append([]string{"dev", "app"}, tc.args...))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != tc.wantTool {
t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
}
if !reflect.DeepEqual(runner.last.Params, tc.wantParams) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams)
}
})
}
}
func TestDevAppCredentialsGetBuildsParams(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "credentials", "get", "--unified-app-id", "u-123"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != "get_dev_app_credentials" {
t.Fatalf("Tool = %q, want get_dev_app_credentials", got)
}
want := map[string]any{"unifiedAppId": "u-123"}
if !reflect.DeepEqual(runner.last.Params, want) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, want)
}
}
func TestDevAppCredentialsGetKeepsSecretFields(t *testing.T) {
runner := &devAppResponseRunner{
response: map[string]any{
"content": map[string]any{
"agentId": 123,
"appKey": "dingxxx",
"appSecret": "secret-app",
"clientSecret": "secret-client",
},
},
}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "credentials", "get", "--unified-app-id", "u-123"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
rendered := out.String()
for _, expected := range []string{"appSecret", "clientSecret", "secret-app", "secret-client"} {
if !strings.Contains(rendered, expected) {
t.Fatalf("credentials output missing %q:\n%s", expected, rendered)
}
}
}
func TestDevAppMemberCommandsValidateRequiredFlags(t *testing.T) {
cases := []struct {
name string
cmd string
args []string
wantErr string
}{
{name: "list requires app", cmd: "list", args: nil, wantErr: "--unified-app-id 为必填"},
{name: "add requires users", cmd: "add", args: []string{"--unified-app-id", "app-001", "--member-type", "DEVELOPER", "--dry-run"}, wantErr: "--user-ids 为必填"},
{name: "add rejects empty users", cmd: "add", args: []string{"--unified-app-id", "app-001", "--user-ids", " , ", "--member-type", "DEVELOPER", "--dry-run"}, wantErr: "--user-ids 至少包含一个 userId"},
{name: "remove requires member type", cmd: "remove", args: []string{"--unified-app-id", "app-001", "--user-ids", "userId1", "--dry-run"}, wantErr: "--member-type 为必填"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(append([]string{"dev", "app", "member", tc.cmd}, tc.args...))
err := root.Execute()
if err == nil {
t.Fatalf("Execute() error = nil, want %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %q, want to contain %q", err.Error(), tc.wantErr)
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
}
})
}
}
func TestDevAppSecurityConfigBuildsOnlyProvidedLists(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{
"dev", "app", "security", "config",
"--unified-app-id", "app-001",
"--ip-whitelist", "192.0.2.10,192.0.2.11",
"--redirect-urls", "https://callback.example.invalid/callback",
"--sso-urls", "https://sso.example.invalid/sso",
"--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.CanonicalProduct; got != "devapp" {
t.Fatalf("CanonicalProduct = %q, want devapp", got)
}
if got := runner.last.Tool; got != "update_dev_app_security_config" {
t.Fatalf("Tool = %q, want update_dev_app_security_config", got)
}
want := map[string]any{
"unifiedAppId": "app-001",
"ipWhitelist": []string{"192.0.2.10", "192.0.2.11"},
"redirectUrls": []string{"https://callback.example.invalid/callback"},
"ssoUrls": []string{"https://sso.example.invalid/sso"},
}
if !reflect.DeepEqual(runner.last.Params, want) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, want)
}
}
func TestDevAppSecurityConfigOmitsAbsentOptionalLists(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "security", "config", "--unified-app-id", "app-001", "--redirect-urls", "https://callback.example.invalid/callback", "--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
want := map[string]any{
"unifiedAppId": "app-001",
"redirectUrls": []string{"https://callback.example.invalid/callback"},
}
if !reflect.DeepEqual(runner.last.Params, want) {
t.Fatalf("Params = %#v, want %#v", runner.last.Params, want)
}
}
func TestDevAppSecurityConfigRequiresAtLeastOneConfig(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "security", "config", "--unified-app-id", "app-001", "--dry-run"})
err := root.Execute()
if err == nil {
t.Fatal("Execute() error = nil, want validation error")
}
if !strings.Contains(err.Error(), "至少提供一项安全配置:--ip-whitelist、--redirect-urls 或 --sso-urls") {
t.Fatalf("error = %q", err.Error())
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
}
}
func TestDevAppMemberAndSecurityRequireWriteGuard(t *testing.T) {
cases := []struct {
name string
args []string
}{
{
name: "member add",
args: []string{"dev", "app", "member", "add", "--unified-app-id", "app-001", "--user-ids", "userId1", "--member-type", "DEVELOPER"},
},
{
name: "member remove",
args: []string{"dev", "app", "member", "remove", "--unified-app-id", "app-001", "--user-ids", "userId1", "--member-type", "DEVELOPER"},
},
{
name: "security config",
args: []string{"dev", "app", "security", "config", "--unified-app-id", "app-001", "--redirect-urls", "https://callback.example.invalid/callback"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatal("Execute() error = nil, want write guard")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("error = %q, want write guard", err.Error())
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
}
})
}
}
func TestEveryDevAppWriteCommandRequiresGuard(t *testing.T) {
paths := [][]string{
{"event", "subscribe"},
{"event", "unsubscribe"},
{"create"},
{"update"},
{"delete"},
{"disable"},
{"enable"},
{"webapp", "config"},
{"permission", "add"},
{"permission", "remove"},
{"member", "add"},
{"member", "remove"},
{"security", "config"},
{"robot", "submit"},
{"robot", "config"},
{"robot", "enable"},
{"robot", "disable"},
{"version", "create"},
{"version", "publish"},
}
for _, path := range paths {
path := path
t.Run(strings.Join(path, "/"), func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(append([]string{"dev", "app"}, path...))
err := root.Execute()
if err == nil {
t.Fatal("Execute() error = nil, want write guard")
}
var appErr *apperrors.Error
if !stderrors.As(err, &appErr) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if appErr.Reason != "confirmation_required" {
t.Fatalf("error reason = %q, want confirmation_required", appErr.Reason)
}
for _, marker := range []string{"需要用户确认", "--dry-run", "--yes"} {
if !strings.Contains(err.Error(), marker) {
t.Fatalf("error = %q, want %q write-guard marker", err.Error(), marker)
}
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no invocation before confirmation", runner.last.Tool)
}
})
}
}
func TestDevAppUnwrapsSuccessfulServiceResult(t *testing.T) {
runner := &devAppResponseRunner{
response: map[string]any{
"content": map[string]any{
"success": true,
"errorCode": nil,
"errorMsg": nil,
"result": map[string]any{
"items": []any{
map[string]any{"versionId": "v-1"},
},
"hasMore": false,
},
},
},
}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "version", "list", "--unified-app-id", "u-1"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
rendered := unwrapDevAppEnvelopeData(t, out.Bytes())
if _, ok := rendered["success"]; ok {
t.Fatalf("output kept ServiceResult wrapper: %#v", rendered)
}
items, ok := rendered["items"].([]any)
if !ok || len(items) != 1 {
t.Fatalf("items = %#v, want one item", rendered["items"])
}
}
func TestDevAppVersionCheckApprovalPreservesApprovalCandidateNames(t *testing.T) {
runner := &devAppResponseRunner{
response: map[string]any{
"content": map[string]any{
"success": true,
"errorCode": nil,
"errorMsg": nil,
"result": map[string]any{
"requiresApproval": true,
"publishable": false,
"approvalMode": "SELECT_APPROVER",
"approvalCandidates": []any{
map[string]any{"userId": "034766", "name": "张三", "mainAdmin": true},
map[string]any{"userId": "084896", "name": "李四", "mainAdmin": false},
},
},
},
},
}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{
"dev", "app", "version", "check-approval",
"--unified-app-id", "u-1",
"--version-id", "v-1",
"--format", "json",
})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Tool; got != devAppVersionPublishTool {
t.Fatalf("Tool = %q, want %q", got, devAppVersionPublishTool)
}
if precheckOnly, _ := runner.last.Params["precheckOnly"].(bool); !precheckOnly {
t.Fatalf("precheckOnly = %#v, want true", runner.last.Params["precheckOnly"])
}
rendered := unwrapDevAppEnvelopeData(t, out.Bytes())
candidates, ok := rendered["approvalCandidates"].([]any)
if !ok || len(candidates) != 2 {
t.Fatalf("approvalCandidates = %#v, want two candidates", rendered["approvalCandidates"])
}
first, ok := candidates[0].(map[string]any)
if !ok {
t.Fatalf("approvalCandidates[0] = %#v, want object", candidates[0])
}
if first["userId"] != "034766" || first["name"] != "张三" || first["mainAdmin"] != true {
t.Fatalf("approvalCandidates[0] = %#v, want userId/name/mainAdmin preserved", first)
}
options := rendered["approvalOptions"].([]any)
if len(options) != 2 {
t.Fatalf("approvalOptions = %#v, want two options", rendered["approvalOptions"])
}
firstOption := devAppRenderedMap(t, options[0])
if firstOption["label"] != "张三(userId: 034766)(主管理员)" || firstOption["name"] != "张三" || firstOption["userId"] != "034766" {
t.Fatalf("approvalOptions[0] = %#v, want label with name/userId/mainAdmin", firstOption)
}
if rendered["completionState"] != "WAITING_FOR_APPROVER_SELECTION" ||
rendered["actionRequired"] != "select_approver" ||
rendered["mustAskUser"] != true ||
rendered["requiresUserInput"] != true ||
rendered["terminal"] != false {
t.Fatalf("selection gate = %#v, want waiting for approver selection", rendered)
}
promptText, _ := rendered["approvalPromptText"].(string)
if !strings.Contains(promptText, "张三(userId: 034766)") ||
!strings.Contains(promptText, "李四(userId: 084896)") ||
!strings.Contains(promptText, "A. ") || !strings.Contains(promptText, "B. ") {
t.Fatalf("approvalPromptText = %q, want numbered readable names", promptText)
}
steps := devAppRenderedSteps(t, rendered)
selectStep := devAppStepByID(t, steps, "select_approver")
if selectStep["blocking"] != true || selectStep["requiresUserInput"] != true {
t.Fatalf("select step = %#v, want blocking user input step", selectStep)
}
publishStep := devAppStepByID(t, steps, "publish_version")
if command, _ := publishStep["command"].(string); !strings.Contains(command, "--approver-user-id <selectedUserId>") {
t.Fatalf("publish command = %q, want selected approver placeholder", command)
}
}
func TestDevAppRobotResultApprovalRequiredWithoutUnifiedAppIDBlocksForUserInput(t *testing.T) {
rendered := runDevAppRobotResultOutput(t, map[string]any{
"status": "APPROVAL_REQUIRED",
"taskId": "t-approval",
"clientId": "dingrobot123",
"clientSecret": "secret-client",
"robotCode": "dingrobot123",
})
if rendered["completionState"] != "BLOCKED_BY_MISSING_UNIFIED_APP_ID" ||
rendered["mustContinue"] != true ||
rendered["mustAskUser"] != true ||
rendered["actionRequired"] != "provide_unified_app_id" ||
rendered["terminal"] != false {
t.Fatalf("top-level completion gate = %#v, want missing unifiedAppId gate", rendered)
}
if message, _ := rendered["message"].(string); !strings.Contains(message, "不能用 clientId/appKey 反查后写版本") {
t.Fatalf("message = %q, want app-key lookup prohibition", message)
}
lifecycle := devAppRenderedMap(t, rendered["lifecycle"])
if lifecycle["publicUseReady"] != false || lifecycle["requiresVersionPublish"] != true {
t.Fatalf("lifecycle = %#v, want publicUseReady=false and requiresVersionPublish=true", lifecycle)
}
if lifecycle["overallComplete"] != false || lifecycle["completionGate"] != "provide_unified_app_id" {
t.Fatalf("lifecycle = %#v, want overallComplete=false and completionGate=provide_unified_app_id", lifecycle)
}
if lifecycle["localOnlyReady"] != true {
t.Fatalf("localOnlyReady = %#v, want true", lifecycle["localOnlyReady"])
}
if lifecycle["localConnectReady"] != true {
t.Fatalf("localConnectReady = %#v, want true", lifecycle["localConnectReady"])
}
wantBlockingIDs := []string{"provide_unified_app_id"}
if got := devAppStringSliceFromRendered(t, lifecycle["blockingStepIds"]); !reflect.DeepEqual(got, wantBlockingIDs) {
t.Fatalf("blockingStepIds = %#v, want %#v", got, wantBlockingIDs)
}
steps := devAppRenderedSteps(t, rendered)
first := devAppRenderedMap(t, steps[0])
if first["id"] != "provide_unified_app_id" {
t.Fatalf("first step id = %#v, want provide_unified_app_id", first["id"])
}
if first["blocking"] != true || first["requiresUserInput"] != true {
t.Fatalf("provide step = %#v, want blocking user input", first)
}
if doneWhen, _ := first["doneWhen"].(string); !strings.Contains(doneWhen, "不能用 clientId/appKey") {
t.Fatalf("provide doneWhen = %q, want app-key prohibition", doneWhen)
}
assertDevAppStepIDAbsent(t, steps, "resolve_unified_app")
assertDevAppStepIDAbsent(t, steps, "create_version")
assertDevAppStepIDAbsent(t, steps, "check_approval")
assertDevAppStepIDAbsent(t, steps, "publish_version")
assertDevAppStepIDAbsent(t, steps, "wait_release")
assertDevAppStepCommandsDoNotContain(t, steps, "dws dev app list --app-key")
assertDevAppStepCommandsDoNotContain(t, steps, "dws dev app version create")
assertDevAppStepCommandsDoNotContain(t, steps, "dws dev app version publish")
connect := devAppStepByID(t, steps, "connect_local")
if connect["blocking"] != false || connect["optional"] != true || connect["scope"] != "local_debug_only" {
t.Fatalf("connect step = %#v, want optional local debug non-blocking step", connect)
}
assertDevAppStepCommandsDoNotContain(t, steps, "secret-client")
}
func TestDevAppRobotResultApprovalRequiredWithUnifiedAppIDAddsPublishAndConnectSteps(t *testing.T) {
rendered := runDevAppRobotResultOutput(t, map[string]any{
"status": "APPROVAL_REQUIRED",
"taskId": "t-approval",
"unifiedAppId": "u-approval",
"clientId": "dingrobot123",
"clientSecret": "secret-client",
"robotCode": "dingrobot123",
})
if rendered["completionState"] != "BLOCKED_BY_VERSION_PUBLISH" ||
rendered["mustContinue"] != true ||
rendered["actionRequired"] != "submit_version_publish" ||
rendered["terminal"] != false {
t.Fatalf("top-level completion gate = %#v, want blocked version publish state", rendered)
}
if message, _ := rendered["message"].(string); !strings.Contains(message, "必须继续执行 blocking nextSteps") {
t.Fatalf("message = %q, want blocking nextSteps guidance", message)
}
lifecycle := devAppRenderedMap(t, rendered["lifecycle"])
if lifecycle["overallComplete"] != false || lifecycle["completionGate"] != "version_publish" {
t.Fatalf("lifecycle = %#v, want overallComplete=false and completionGate=version_publish", lifecycle)
}
wantBlockingIDs := []string{"create_version", "check_approval", "publish_version", "wait_release"}
if got := devAppStringSliceFromRendered(t, lifecycle["blockingStepIds"]); !reflect.DeepEqual(got, wantBlockingIDs) {
t.Fatalf("blockingStepIds = %#v, want %#v", got, wantBlockingIDs)
}
steps := devAppRenderedSteps(t, rendered)
if first := devAppRenderedMap(t, steps[0]); first["id"] != "create_version" {
t.Fatalf("first step = %#v, want create_version before connect_local", first)
}
create := devAppStepByID(t, steps, "create_version")
if create["blocking"] != true {
t.Fatalf("create blocking = %#v, want true", create["blocking"])
}
if command, _ := create["command"].(string); !strings.Contains(command, "--unified-app-id u-approval") {
t.Fatalf("create command = %q, want concrete unifiedAppId", command)
}
if dryRun, _ := create["dryRunCommand"].(string); !strings.Contains(dryRun, "--dry-run") {
t.Fatalf("create dryRunCommand = %q, want --dry-run", dryRun)
}
publish := devAppStepByID(t, steps, "publish_version")
if publish["requiresUserInput"] != true {
t.Fatalf("publish requiresUserInput = %#v, want true", publish["requiresUserInput"])
}
if publish["blocking"] != true {
t.Fatalf("publish blocking = %#v, want true", publish["blocking"])
}
if doneWhen, _ := publish["doneWhen"].(string); !strings.Contains(doneWhen, "approvalSubmitted=true") || !strings.Contains(doneWhen, "UNDER_REVIEW") {
t.Fatalf("publish doneWhen = %q, want published/submitted approval gate", doneWhen)
}
connect := devAppStepByID(t, steps, "connect_local")
if connect["blocking"] != false || connect["optional"] != true || connect["scope"] != "local_debug_only" {
t.Fatalf("connect step = %#v, want optional local debug non-blocking step", connect)
}
sensitive := connect["sensitiveFields"].([]any)
if len(sensitive) != 1 || sensitive[0] != "clientSecret" {
t.Fatalf("sensitiveFields = %#v, want clientSecret", sensitive)
}
assertDevAppStepCommandsDoNotContain(t, steps, "secret-client")
}
func TestDevAppRobotResultWaitingAddsPollStep(t *testing.T) {
rendered := runDevAppRobotResultOutput(t, map[string]any{
"status": "WAITING",
"taskId": "t-wait",
"intervalSeconds": 5,
})
lifecycle := devAppRenderedMap(t, rendered["lifecycle"])
if lifecycle["phase"] != "creating" || lifecycle["robotTaskDone"] != false {
t.Fatalf("lifecycle = %#v, want creating and not done", lifecycle)
}
steps := devAppRenderedSteps(t, rendered)
poll := devAppStepByID(t, steps, "poll_robot_result")
if command, _ := poll["command"].(string); command != "dws dev app robot result --task-id t-wait --format json" {
t.Fatalf("poll command = %q, want task polling command", command)
}
}
func TestDevAppRobotResultSuccessAddsPublishAndConnectSteps(t *testing.T) {
rendered := runDevAppRobotResultOutput(t, map[string]any{
"status": "SUCCESS",
"taskId": "t-ok",
"unifiedAppId": "u-1",
"clientId": "dingrobot123",
"clientSecret": "secret-client",
})
lifecycle := devAppRenderedMap(t, rendered["lifecycle"])
if lifecycle["localConnectReady"] != true || lifecycle["requiresVersionPublish"] != true || lifecycle["publicUseReady"] != false {
t.Fatalf("lifecycle = %#v, want local ready, version required, public not ready", lifecycle)
}
if lifecycle["overallComplete"] != false || lifecycle["completionGate"] != "version_publish" {
t.Fatalf("lifecycle = %#v, want robot result not complete until version publish", lifecycle)
}
if rendered["completionState"] != "BLOCKED_BY_VERSION_PUBLISH" ||
rendered["mustContinue"] != true ||
rendered["actionRequired"] != "submit_version_publish" ||
rendered["terminal"] != false {
t.Fatalf("top-level completion gate = %#v, want blocked version publish state", rendered)
}
if message, _ := rendered["message"].(string); !strings.Contains(message, "线上发布/审批未完成") {
t.Fatalf("message = %q, want publish/approval incomplete guidance", message)
}
wantBlockingIDs := []string{"create_version", "check_approval", "publish_version", "wait_release"}
if got := devAppStringSliceFromRendered(t, lifecycle["blockingStepIds"]); !reflect.DeepEqual(got, wantBlockingIDs) {
t.Fatalf("blockingStepIds = %#v, want %#v", got, wantBlockingIDs)
}
steps := devAppRenderedSteps(t, rendered)
if first := devAppRenderedMap(t, steps[0]); first["id"] == "resolve_unified_app" {
t.Fatalf("first step = %#v, did not expect resolve step when unifiedAppId is present", first)
}
create := devAppStepByID(t, steps, "create_version")
if create["blocking"] != true {
t.Fatalf("create blocking = %#v, want true", create["blocking"])
}
if command, _ := create["command"].(string); !strings.Contains(command, "--unified-app-id u-1") {
t.Fatalf("create command = %q, want concrete unifiedAppId", command)
}
connect := devAppStepByID(t, steps, "connect_local")
if connect["blocking"] != false || connect["optional"] != true || connect["scope"] != "local_debug_only" {
t.Fatalf("connect step = %#v, want optional local debug non-blocking step", connect)
}
if command, _ := connect["command"].(string); !strings.Contains(command, "--unified-app-id u-1") {
t.Fatalf("connect command = %q, want --unified-app-id form (safe: clientSecret not on argv)", command)
}
if command, _ := connect["command"].(string); strings.Contains(command, "--robot-client-secret") {
t.Fatalf("connect command = %q, must not put clientSecret on argv", command)
}
assertDevAppStepCommandsDoNotContain(t, steps, "secret-client")
}
func TestDevAppRobotResultSuccessWithoutUnifiedAppIDBlocksForUserInput(t *testing.T) {
rendered := runDevAppRobotResultOutput(t, map[string]any{
"status": "SUCCESS",
"taskId": "t-ok",
"clientId": "dingrobot123",
"clientSecret": "secret-client",
})
if rendered["completionState"] != "BLOCKED_BY_MISSING_UNIFIED_APP_ID" ||
rendered["mustAskUser"] != true ||
rendered["actionRequired"] != "provide_unified_app_id" ||
rendered["terminal"] != false {
t.Fatalf("top-level completion gate = %#v, want missing unifiedAppId gate", rendered)
}
lifecycle := devAppRenderedMap(t, rendered["lifecycle"])
if lifecycle["overallComplete"] != false || lifecycle["completionGate"] != "provide_unified_app_id" {
t.Fatalf("lifecycle = %#v, want robot result blocked on unifiedAppId", lifecycle)
}
wantBlockingIDs := []string{"provide_unified_app_id"}
if got := devAppStringSliceFromRendered(t, lifecycle["blockingStepIds"]); !reflect.DeepEqual(got, wantBlockingIDs) {
t.Fatalf("blockingStepIds = %#v, want %#v", got, wantBlockingIDs)
}
steps := devAppRenderedSteps(t, rendered)
first := devAppRenderedMap(t, steps[0])
if first["id"] != "provide_unified_app_id" || first["blocking"] != true {
t.Fatalf("first step = %#v, want blocking provide_unified_app_id", first)
}
assertDevAppStepIDAbsent(t, steps, "resolve_unified_app")
assertDevAppStepIDAbsent(t, steps, "create_version")
assertDevAppStepIDAbsent(t, steps, "publish_version")
assertDevAppStepCommandsDoNotContain(t, steps, "dws dev app list --app-key")
assertDevAppStepCommandsDoNotContain(t, steps, "dws dev app version create")
connect := devAppStepByID(t, steps, "connect_local")
if connect["blocking"] != false || connect["optional"] != true || connect["scope"] != "local_debug_only" {
t.Fatalf("connect step = %#v, want optional local debug non-blocking step", connect)
}
assertDevAppStepCommandsDoNotContain(t, steps, "secret-client")
}
func TestDevAppRobotResultFailAndExpiredAreTerminalFailures(t *testing.T) {
cases := []struct {
name string
status string
}{
{name: "fail", status: "FAIL"},
{name: "expired", status: "EXPIRED"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runner := &devAppResponseRunner{response: map[string]any{
"content": map[string]any{
"success": true,
"result": map[string]any{"status": tc.status, "taskId": "t-retry"},
},
}}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "robot", "result", "--task-id", "t-1", "--format", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
var env struct {
OK bool `json:"ok"`
Outcome string `json:"outcome"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
if env.OK || env.Outcome != "failure" || env.Error == nil || env.Error.Type != "api" {
t.Fatalf("terminal %s envelope = %+v, want API failure", tc.status, env)
}
})
}
}
func runDevAppRobotResultOutput(t *testing.T, result map[string]any) map[string]any {
t.Helper()
runner := &devAppResponseRunner{
response: map[string]any{
"content": map[string]any{
"success": true,
"errorCode": nil,
"errorMsg": nil,
"result": result,
},
},
}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"dev", "app", "robot", "result", "--task-id", "t-1", "--format", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
var env struct {
OK bool `json:"ok"`
Outcome string `json:"outcome"`
Data map[string]any `json:"data"`
Meta *struct {
Operation *struct {
ID string `json:"id"`
State string `json:"state"`
NextCommand string `json:"next_command"`
} `json:"operation"`
} `json:"meta"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.Bytes())
}
if !env.OK || env.Outcome != "pending" || env.Data == nil || env.Meta == nil || env.Meta.Operation == nil {
t.Fatalf("async envelope = %+v, want pending with data and operation", env)
}
if env.Meta.Operation.ID == "" || env.Meta.Operation.State == "" || env.Meta.Operation.NextCommand == "" {
t.Fatalf("pending operation = %+v, want id/state/next_command", env.Meta.Operation)
}
return env.Data
}
// unwrapDevAppEnvelopeData 解析 dev app 树统一信封输出并返回 data 层
// (统一输出 dev 域试点,队列 Phase F):既有断言直接消费裸载荷,
// 信封化后先校验 ok/outcome,再返回 data(业务载荷形状不变)。
func unwrapDevAppEnvelopeData(t *testing.T, raw []byte) map[string]any {
t.Helper()
var env struct {
OK bool `json:"ok"`
Outcome string `json:"outcome"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, raw)
}
if !env.OK || env.Outcome != "success" {
t.Fatalf("envelope ok/outcome = %v/%q, want true/success: %s", env.OK, env.Outcome, raw)
}
if env.Data == nil {
t.Fatalf("envelope data is nil: %s", raw)
}
return env.Data
}
func devAppRenderedSteps(t *testing.T, rendered map[string]any) []any {
t.Helper()
steps, ok := rendered["nextSteps"].([]any)
if !ok || len(steps) == 0 {
t.Fatalf("nextSteps = %#v, want non-empty array", rendered["nextSteps"])
}
return steps
}
func devAppStepByID(t *testing.T, steps []any, id string) map[string]any {
t.Helper()
for _, raw := range steps {
step := devAppRenderedMap(t, raw)
if step["id"] == id {
return step
}
}
t.Fatalf("step %q not found in %#v", id, steps)
return nil
}
func assertDevAppStepIDAbsent(t *testing.T, steps []any, id string) {
t.Helper()
for _, raw := range steps {
step := devAppRenderedMap(t, raw)
if step["id"] == id {
t.Fatalf("step %q unexpectedly present in %#v", id, steps)
}
}
}
func devAppRenderedMap(t *testing.T, raw any) map[string]any {
t.Helper()
m, ok := raw.(map[string]any)
if !ok {
t.Fatalf("value = %#v, want object", raw)
}
return m
}
func devAppStringSliceFromRendered(t *testing.T, raw any) []string {
t.Helper()
values, ok := raw.([]any)
if !ok {
t.Fatalf("value = %#v, want array", raw)
}
out := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
t.Fatalf("array value = %#v, want string", value)
}
out = append(out, text)
}
return out
}
func assertDevAppStepCommandsDoNotContain(t *testing.T, steps []any, secret string) {
t.Helper()
for _, raw := range steps {
step := devAppRenderedMap(t, raw)
for _, key := range []string{"command", "dryRunCommand"} {
command, _ := step[key].(string)
if strings.Contains(command, secret) {
t.Fatalf("%s for step %s leaked secret in %q", key, step["id"], command)
}
}
}
}
func TestNormalizeDevAppRemovePermissionScopeValues(t *testing.T) {
result := executor.Result{
Response: map[string]any{
"content": map[string]any{
"removedScopeValues": []any{
map[string]any{"scopeValue": "qyapi_robot_sendmsg", "scopeName": "机器人发消息"},
map[string]any{"scopeValue": "Contact.User.mobile", "scopeName": "手机号"},
},
},
},
}
normalized := normalizeDevAppToolResult(devAppPermissionRmTool, result)
content := normalized.Response["content"].(map[string]any)
got := content["removedScopeValues"]
want := []string{"qyapi_robot_sendmsg", "Contact.User.mobile"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("removedScopeValues = %#v, want %#v", got, want)
}
}
func TestNormalizeDevAppLifecycleResult(t *testing.T) {
disable := normalizeDevAppToolResult(devAppDisableTool, executor.Result{
Response: map[string]any{"content": map[string]any{"deleted": false}},
})
disableContent := disable.Response["content"].(map[string]any)
if disabled, _ := disableContent["disabled"].(bool); !disabled {
t.Fatalf("disabled = %#v, want true", disableContent["disabled"])
}
enable := normalizeDevAppToolResult(devAppEnableTool, executor.Result{
Response: map[string]any{"content": map[string]any{"deleted": false}},
})
enableContent := enable.Response["content"].(map[string]any)
if enabled, _ := enableContent["enabled"].(bool); !enabled {
t.Fatalf("enabled = %#v, want true", enableContent["enabled"])
}
}