276 lines
11 KiB
Go
276 lines
11 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
|
||
package helpers
|
||
|
||
import (
|
||
"bytes"
|
||
stderrors "errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||
)
|
||
|
||
// runDevDomainErrorCase 在生产口径的静默根下执行错误路径用例(队列
|
||
// B128~B137)。生产根命令设 SilenceUsage/SilenceErrors=true
|
||
// (internal/app/root.go),错误时 Cobra 不向 stdout 打 usage/error;测试根
|
||
// 对齐同一口径,使「错误路径 stdout 零字节」断言与真实调用一致。
|
||
func runDevDomainErrorCase(t *testing.T, runner executor.Runner, args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
|
||
t.Helper()
|
||
root := newDevAppTestRoot(runner)
|
||
root.SilenceUsage = true
|
||
root.SilenceErrors = true
|
||
var out, errBuf bytes.Buffer
|
||
root.SetOut(&out)
|
||
root.SetErr(&errBuf)
|
||
root.SetIn(strings.NewReader(""))
|
||
root.SetArgs(args)
|
||
err := root.Execute()
|
||
return &out, &errBuf, err
|
||
}
|
||
|
||
// requireDevValidationError 断言错误是结构化 apperrors validation(AC-03):
|
||
// Category=validation + rc=3 + 消息可定位。
|
||
func requireDevValidationError(t *testing.T, err error, wantMsg string) *apperrors.Error {
|
||
t.Helper()
|
||
if err == nil {
|
||
t.Fatalf("error = nil, want structured validation error containing %q", wantMsg)
|
||
}
|
||
var appErr *apperrors.Error
|
||
if !stderrors.As(err, &appErr) {
|
||
t.Fatalf("error type = %T (%v), want structured *errors.Error", err, err)
|
||
}
|
||
if appErr.Category != apperrors.CategoryValidation {
|
||
t.Fatalf("category = %q, want validation", appErr.Category)
|
||
}
|
||
if got := apperrors.ExitCode(err); got != 3 {
|
||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||
}
|
||
if !strings.Contains(err.Error(), wantMsg) {
|
||
t.Fatalf("message = %q, want contains %q", err.Error(), wantMsg)
|
||
}
|
||
return appErr
|
||
}
|
||
|
||
// TestDevAppGetMissingLocatorStructuredValidation 是队列 B128:dev app get
|
||
// 无参数二选一报结构化 validation(rc=3),stdout 零字节。
|
||
func TestDevAppGetMissingLocatorStructuredValidation(t *testing.T) {
|
||
out, _, err := runDevDomainErrorCase(t, &captureRunner{}, "dev", "app", "get")
|
||
requireDevValidationError(t, err, "请传入 --unified-app-id 或 --app-key")
|
||
if out.Len() != 0 {
|
||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||
}
|
||
}
|
||
|
||
// TestDevConnectUnknownChannelStructuredValidation 是队列 B129:dev connect
|
||
// 未知渠道报结构化 validation(NewValidation,rc=3),stdout 零字节。
|
||
func TestDevConnectUnknownChannelStructuredValidation(t *testing.T) {
|
||
clearChannelEnv(t)
|
||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||
"dev", "connect", "--channel", "bogus")
|
||
requireDevValidationError(t, err, `未知渠道 "bogus"`)
|
||
if out.Len() != 0 {
|
||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||
}
|
||
}
|
||
|
||
// TestDevConnectMissingCredentialsNonInteractiveStructuredValidation 是队列
|
||
// B130:非交互环境缺凭证不进引导流程,直接报结构化 validation(rc=3)。
|
||
// stdin 交互判定经 seam 显式置 false,保证脚本/CI 环境断言确定性。
|
||
func TestDevConnectMissingCredentialsNonInteractiveStructuredValidation(t *testing.T) {
|
||
clearChannelEnv(t)
|
||
origInteractive := devAppConnectStdinInteractive
|
||
t.Cleanup(func() { devAppConnectStdinInteractive = origInteractive })
|
||
devAppConnectStdinInteractive = func() bool { return false }
|
||
|
||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||
"dev", "connect", "--channel", "hermes")
|
||
requireDevValidationError(t, err, "需要 --robot-client-id/--robot-client-secret")
|
||
if out.Len() != 0 {
|
||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||
}
|
||
}
|
||
|
||
// TestDevConnectRestartMissingRecordStructuredValidation 是队列 B131 的结构化
|
||
// 增量:restart 无连接器记录报结构化 validation(rc=3)。错误消息与 stdout 零
|
||
// 字节断言已由轮 11-B0 B116(TestConnectDaemonFamilyMissingDaemonErrorPaths)
|
||
// 覆盖,本测试补 AC-03 的 Category/rc 映射面。
|
||
func TestDevConnectRestartMissingRecordStructuredValidation(t *testing.T) {
|
||
connectDaemonDirOverride = t.TempDir()
|
||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||
|
||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||
"dev", "connect", "restart", "--robot-client-id", "ghost")
|
||
requireDevValidationError(t, err, "未找到连接器记录")
|
||
if out.Len() != 0 {
|
||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||
}
|
||
}
|
||
|
||
// TestDevConnectStatusDaemonDirResolutionFailure 是队列 B133:status 的
|
||
// daemon 目录解析失败(override 指向普通文件使 MkdirAll 失败)报结构化
|
||
// internal 错误(rc=5),stdout 零字节。
|
||
func TestDevConnectStatusDaemonDirResolutionFailure(t *testing.T) {
|
||
blocked := filepath.Join(t.TempDir(), "blocked")
|
||
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
|
||
t.Fatalf("setup blocked file: %v", err)
|
||
}
|
||
connectDaemonDirOverride = blocked
|
||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||
|
||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||
"dev", "connect", "status", "--robot-client-id", "ghost", "--json")
|
||
if err == nil {
|
||
t.Fatalf("error = nil, want daemon dir resolution failure")
|
||
}
|
||
var appErr *apperrors.Error
|
||
if !stderrors.As(err, &appErr) {
|
||
t.Fatalf("error type = %T (%v), want structured *errors.Error", err, err)
|
||
}
|
||
if appErr.Category != apperrors.CategoryInternal {
|
||
t.Fatalf("category = %q, want internal", appErr.Category)
|
||
}
|
||
if got := apperrors.ExitCode(err); got != 5 {
|
||
t.Fatalf("exit code = %d, want 5 (internal)", got)
|
||
}
|
||
if !strings.Contains(err.Error(), "resolve daemon dir") {
|
||
t.Fatalf("message = %q, want contains resolve daemon dir", err.Error())
|
||
}
|
||
if out.Len() != 0 {
|
||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||
}
|
||
}
|
||
|
||
// TestDevAppCreateMissingNameErrorPath 是队列 B134:create 缺 --name 报错、
|
||
// stdout 零字节。LeafSpec RequiredHint 由 corecmd 统一转成 typed validation,
|
||
// 与手写参数校验共享 rc=3。
|
||
func TestDevAppCreateMissingNameErrorPath(t *testing.T) {
|
||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||
"dev", "app", "create", "--yes")
|
||
if err == nil || !strings.Contains(err.Error(), "--name 为必填") {
|
||
t.Fatalf("error = %v, want --name 为必填", err)
|
||
}
|
||
var appErr *apperrors.Error
|
||
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||
t.Fatalf("error = %T (%v), want typed validation", err, err)
|
||
}
|
||
if got := apperrors.ExitCode(err); got != 3 {
|
||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||
}
|
||
if out.Len() != 0 {
|
||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||
}
|
||
}
|
||
|
||
// TestDevAppVersionPublishMissingParamsErrorPath 是队列 B135:version publish
|
||
// 参数缺失报错、stdout 零字节。ValidateRequired 按声明顺序报第一个缺失的
|
||
// 必填参数(先 unified-app-id 后 version-id);两者均为 typed validation rc=3。
|
||
func TestDevAppVersionPublishMissingParamsErrorPath(t *testing.T) {
|
||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||
"dev", "app", "version", "publish", "--yes")
|
||
if err == nil || !strings.Contains(err.Error(), "--unified-app-id 为必填") {
|
||
t.Fatalf("error = %v, want --unified-app-id 为必填", err)
|
||
}
|
||
if got := apperrors.ExitCode(err); got != 3 {
|
||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||
}
|
||
if out.Len() != 0 {
|
||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||
}
|
||
|
||
out2, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||
"dev", "app", "version", "publish", "--yes", "--unified-app-id", "u-1")
|
||
if err == nil || !strings.Contains(err.Error(), "--version-id 为必填") {
|
||
t.Fatalf("error = %v, want --version-id 为必填", err)
|
||
}
|
||
if got := apperrors.ExitCode(err); got != 3 {
|
||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||
}
|
||
if out2.Len() != 0 {
|
||
t.Fatalf("error path must keep stdout empty, got %q", out2.String())
|
||
}
|
||
}
|
||
|
||
// TestDevAppDeleteDisableEnableMissingUnifiedAppID 是队列 B136:
|
||
// delete/disable/enable 缺统一应用标识报错、stdout 零字节,均由统一 required
|
||
// preflight 返回 typed validation rc=3。
|
||
func TestDevAppDeleteDisableEnableMissingUnifiedAppID(t *testing.T) {
|
||
for _, leaf := range []string{"delete", "disable", "enable"} {
|
||
t.Run(leaf, func(t *testing.T) {
|
||
out, _, err := runDevDomainErrorCase(t, &captureRunner{},
|
||
"dev", "app", leaf, "--yes")
|
||
if err == nil || !strings.Contains(err.Error(), "--unified-app-id 为必填") {
|
||
t.Fatalf("error = %v, want --unified-app-id 为必填", err)
|
||
}
|
||
var appErr *apperrors.Error
|
||
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||
t.Fatalf("error = %T (%v), want typed validation", err, err)
|
||
}
|
||
if got := apperrors.ExitCode(err); got != 3 {
|
||
t.Fatalf("exit code = %d, want 3 (validation)", got)
|
||
}
|
||
if out.Len() != 0 {
|
||
t.Fatalf("error path must keep stdout empty, got %q", out.String())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestDevDomainErrorPathsKeepStdoutEmpty 是队列 B137 的总断言(AC-11):
|
||
// dev 域全部错误路径 stdout 严格零字节。dev 域失败走 apperrors 通道(生产
|
||
// root 错误处理器写 stderr),Phase F 不引入 failure 信封(轮 8 裁决⑪),
|
||
// 故 AC-11 在测试层的投影即「stdout 零字节」。各用例的结构化/消息断言见
|
||
// 上述分测试,本表只锁流纪律。
|
||
func TestDevDomainErrorPathsKeepStdoutEmpty(t *testing.T) {
|
||
daemonDirSetup := func(t *testing.T) {
|
||
t.Helper()
|
||
connectDaemonDirOverride = t.TempDir()
|
||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||
}
|
||
cases := []struct {
|
||
name string
|
||
args []string
|
||
setup func(t *testing.T)
|
||
}{
|
||
{"app get missing locator", []string{"dev", "app", "get"}, nil},
|
||
{"app update no field", []string{"dev", "app", "update", "--unified-app-id", "u-1", "--dry-run"}, nil},
|
||
{"app create missing name", []string{"dev", "app", "create", "--yes"}, nil},
|
||
{"app delete missing id", []string{"dev", "app", "delete", "--yes"}, nil},
|
||
{"app disable missing id", []string{"dev", "app", "disable", "--yes"}, nil},
|
||
{"app enable missing id", []string{"dev", "app", "enable", "--yes"}, nil},
|
||
{"version publish missing params", []string{"dev", "app", "version", "publish", "--yes"}, nil},
|
||
{"connect unknown channel", []string{"dev", "connect", "--channel", "bogus"}, nil},
|
||
{"connect status missing locator", []string{"dev", "connect", "status"}, nil},
|
||
{"connect restart missing record", []string{"dev", "connect", "restart", "--robot-client-id", "ghost"}, daemonDirSetup},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
if tc.setup != nil {
|
||
tc.setup(t)
|
||
}
|
||
out, _, err := runDevDomainErrorCase(t, &captureRunner{}, tc.args...)
|
||
if err == nil {
|
||
t.Fatalf("want error, got nil\nstdout:\n%s", out.String())
|
||
}
|
||
if out.Len() != 0 {
|
||
t.Fatalf("error path must keep stdout empty (AC-11), got %q", out.String())
|
||
}
|
||
})
|
||
}
|
||
}
|