238 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|