Files
dingtalk-workspace-cli/internal/helpers/errors_test.go
T

238 lines
9.3 KiB
Go

package helpers
import (
"errors"
"fmt"
"strings"
"testing"
)
func TestCrossPlatformCoverageCLIErrorFormattingExitCodesAndJSON(t *testing.T) {
cause := errors.New("root cause")
err := &CLIError{Code: CodeInvalidParam, Message: "bad input", Suggestion: "fix it", Operation: "doc/read", Cause: cause}
if got := err.Error(); !strings.Contains(got, "doc/read") || !strings.Contains(got, "fix it") {
t.Fatalf("Error() = %q", got)
}
if !errors.Is(err, cause) || err.ExitCode() != ExitValidation {
t.Fatalf("Unwrap/ExitCode = %v/%d", err.Unwrap(), err.ExitCode())
}
encoded := err.ToJSON()["error"].(map[string]any)
for _, key := range []string{"code", "message", "exit_code", "operation", "suggestion", "cause"} {
if _, ok := encoded[key]; !ok {
t.Errorf("ToJSON() missing %q: %#v", key, encoded)
}
}
minimal := (&CLIError{Code: CodeUnclassified, Message: "bad"}).ToJSON()["error"].(map[string]any)
if len(minimal) != 3 {
t.Fatalf("minimal ToJSON() = %#v", minimal)
}
exitCases := map[string]int{
CodeAuthNotConfigured: ExitAuth, CodeAuthTokenExpired: ExitAuth,
CodeAuthPermission: ExitPermission, CodeMissingParam: ExitValidation,
CodeInvalidParam: ExitValidation, CodeInvalidJSON: ExitValidation,
CodeInvalidPath: ExitValidation, CodeInputTooLarge: ExitValidation,
CodeFileNotFound: ExitValidation, CodeContentTruncated: ExitAPI,
CodeMCPServerError: ExitAPI, CodeMCPToolError: ExitAPI,
CodeNetworkTimeout: ExitAPI, CodeNetworkUnreachable: ExitAPI,
CodeResourceNotFound: ExitAPI, CodeTableNotFound: ExitAPI,
CodeSheetNotFound: ExitAPI, CodeFieldNotFound: ExitAPI,
CodeRecordNotFound: ExitAPI, CodeLockTimeout: ExitInternal,
CodeUnclassified: ExitInternal, "unknown": ExitInternal,
}
for code, want := range exitCases {
if got := (&CLIError{Code: code}).ExitCode(); got != want {
t.Errorf("ExitCode(%s) = %d, want %d", code, got, want)
}
}
pat := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
if pat.Error() != pat.RawJSON || pat.RawStderr() != pat.RawJSON || pat.ExitCode() != ExitPermission {
t.Fatalf("PATError methods changed: %#v", pat)
}
}
func TestCrossPlatformCoverageWrapErrorClassifiesEveryErrorFamily(t *testing.T) {
if WrapError(nil) != nil {
t.Fatal("WrapError(nil) != nil")
}
existing := &CLIError{Code: CodeInvalidParam}
if WrapError(existing) != existing {
t.Fatal("WrapError changed an existing CLIError")
}
pat := &PATError{RawJSON: `{}`}
if WrapError(pat) != pat {
t.Fatal("WrapError changed an existing PATError")
}
tests := []struct {
message string
op string
code string
hint string
}{
{"timeout acquiring data lock", "", CodeLockTimeout, "file lock"},
{"lookup: no such host", "", CodeNetworkUnreachable, "DNS"},
{"TLS handshake failed", "", CodeNetworkUnreachable, "TLS"},
{"connection refused", "", CodeNetworkUnreachable, "connect"},
{"context deadline exceeded", "", CodeNetworkTimeout, "timed out"},
{"USER_TOKEN_ILLEGAL", "", CodeAuthTokenExpired, "Token"},
{"api_key_expired", "", CodeAuthTokenExpired, "API Key"},
{"Missing service_id or access_key", "", CodeAuthNotConfigured, "未登录"},
{"403 forbidden", "", CodeAuthPermission, "Permission"},
{"permission denied for doc", "", CodeAuthPermission, "document owner"},
{"permission denied for aitable base", "", CodeAuthPermission, "base list"},
{"permission denied for chat group", "", CodeAuthPermission, "group member"},
{"server preserved detail", "sheet/batch_update", CodeMCPServerError, "preserved detail"},
{"resource not found", "", CodeResourceNotFound, "resource"},
{"table not found", "", CodeTableNotFound, "doc search"},
{"群不存在", "", CodeResourceNotFound, "disbanded"},
{"文档不存在", "", CodeResourceNotFound, "deleted"},
{"MCP不存在 PARAM_ERROR", "", CodeMCPServerError, "未注册"},
{"invalid character in JSON", "", CodeInvalidJSON, "JSON"},
{"add_base_record json: bad", "", CodeInvalidJSON, "--data"},
{"字段 fieldId json: bad", "", CodeInvalidJSON, "fieldId"},
{"HTTP 503", "", CodeMCPServerError, "internal"},
{"操作失败", "", CodeMCPToolError, "verbose"},
{"字段类型操作失败", "", CodeMCPToolError, "field get"},
{"tool 调用失败", "", CodeMCPToolError, "verbose"},
{"搜索内容不能为空", "", CodeMCPToolError, "doc search"},
{"totally unknown", "trace/op", CodeUnclassified, "totally unknown"},
}
for _, tc := range tests {
t.Run(tc.message, func(t *testing.T) {
wrapped := WrapErrorWithOperation(errors.New(tc.message), tc.op)
cli, ok := wrapped.(*CLIError)
if !ok || cli.Code != tc.code || !strings.Contains(cli.Error(), tc.hint) {
t.Fatalf("WrapErrorWithOperation() = %#v (%v)", wrapped, wrapped)
}
if tc.op != "" && cli.Operation != tc.op {
t.Fatalf("operation = %q, want %q", cli.Operation, tc.op)
}
})
}
if !IsAuthError(&CLIError{Code: CodeAuthTokenExpired}) || IsAuthError(errors.New("AUTH_TOKEN_EXPIRED")) || IsAuthError(&CLIError{Code: CodeAuthPermission}) {
t.Fatal("IsAuthError classification changed")
}
}
func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *testing.T) {
suggestions := map[string]string{
"搜索内容不能为空": "doc search",
"User has no permission to access this email": "mailbox list",
"频率超限": "rate limit",
"rate limit": "rate limit",
"未找到指定工具": "未注册",
"MCP不存在": "未注册",
"参数错误": "input parameters",
"param error": "input parameters",
}
for input, want := range suggestions {
if got := suggestForBusinessErrorText(input); !strings.Contains(got, want) {
t.Errorf("suggestForBusinessErrorText(%q) = %q", input, got)
}
}
if suggestForBusinessErrorText("ok") != "" {
t.Fatal("unknown business text produced a suggestion")
}
if ClassifyToolResultContent(map[string]any{"ok": true}) != nil {
t.Fatal("successful tool content was classified as an error")
}
for _, key := range []string{"errorCode", "error_code", "code"} {
err := ClassifyToolResultContent(map[string]any{key: "USER_TOKEN_ILLEGAL"})
if cli, ok := err.(*CLIError); !ok || cli.Code != CodeAuthTokenExpired {
t.Errorf("gateway key %s classified as %#v", key, err)
}
}
for _, key := range []string{"code", "errorCode"} {
err := ClassifyToolResultContent(map[string]any{key: "PAT_NO_PERMISSION", "data": map[string]any{"class": "secret", "allowed": true}})
if _, ok := err.(*PATError); !ok {
t.Errorf("PAT key %s classified as %#v", key, err)
}
}
for _, tc := range []struct {
text string
kind string
}{
{"not-json", "nil"},
{`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli"},
{`{"error":"Missing service_id or access_key"}`, "cli"},
{`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat"},
{`{"success":false,"errorMsg":"rate limit"}`, "cli"},
{`{"success":true}`, "nil"},
} {
err := ClassifyMCPResponseText(tc.text)
switch tc.kind {
case "nil":
if err != nil {
t.Errorf("ClassifyMCPResponseText(%s) = %v", tc.text, err)
}
case "cli":
if _, ok := err.(*CLIError); !ok {
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
}
case "pat":
if _, ok := err.(*PATError); !ok {
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
}
}
}
}
func TestCrossPlatformCoveragePATCleanupAndMatchingHelpers(t *testing.T) {
cleaned := cleanPATJSON(map[string]any{
"code": "PAT_NO_PERMISSION",
"data": map[string]any{"class": "secret", "items": []any{map[string]any{"class": "hidden", "keep": true}}},
}, "PAT_NO_PERMISSION")
if strings.Contains(cleaned, "class") || !strings.Contains(cleaned, "keep") {
t.Fatalf("cleanPATJSON(data) = %s", cleaned)
}
cleaned = cleanPATJSON(map[string]any{"code": "x", "extra": map[string]any{"class": "hidden", "value": 1}}, "x")
if strings.Contains(cleaned, "class") || !strings.Contains(cleaned, "value") {
t.Fatalf("cleanPATJSON(fallback) = %s", cleaned)
}
cleaned = cleanPATJSON(map[string]any{"code": "x"}, "x")
if strings.Contains(cleaned, "data") {
t.Fatalf("cleanPATJSON(empty fallback) = %s", cleaned)
}
cleaned = cleanPATJSON(map[string]any{"data": make(chan int)}, "x")
if cleaned != `{"success":false,"code":"x"}` {
t.Fatalf("cleanPATJSON(marshal error) = %s", cleaned)
}
if stripClassFields("value") != "value" {
t.Fatal("stripClassFields scalar changed")
}
if !errContainsAny("TLS Handshake", "tls") || errContainsAny("ok", "bad") {
t.Fatal("errContainsAny matching changed")
}
for _, tc := range []struct {
input string
word string
want bool
}{
{"HTTP 500 error", "500", true}, {"x500y", "500", false},
{"before 500", "500", true}, {"500after", "500", false}, {"none", "500", false},
{"aaa", "a", false},
} {
if got := errContainsWord(tc.input, tc.word); got != tc.want {
t.Errorf("errContainsWord(%q, %q) = %v", tc.input, tc.word, got)
}
}
for _, value := range []byte{'0', '9', 'a', 'z', 'A', 'Z'} {
if !isAlnum(value) {
t.Errorf("isAlnum(%q) = false", value)
}
}
if isAlnum('-') {
t.Fatal("isAlnum('-') = true")
}
if got := fmt.Sprint(stripClassFields([]any{map[string]any{"class": "x", "v": 1}})); strings.Contains(got, "class") {
t.Fatalf("stripClassFields slice = %s", got)
}
}