1741 lines
60 KiB
Go
1741 lines
60 KiB
Go
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"])
|
||
}
|
||
}
|