Files
dingtalk-workspace-cli/internal/errors/errors_extra_test.go
T
audanye-sudoandClaude Opus 4.6 7fda120d5a feat: enhance error diagnostics with trace ID, server error code, and comprehensive logging
Improve CLI error output and local logging to enable offline issue diagnosis:

- Add ServerDiagnostics struct to extract and propagate trace_id, server_error_code,
  technical_detail, and server_retryable from MCP server responses
- Extract diagnostics from JSON-RPC error.data, tool call result content, and HTTP
  response headers (X-Trace-Id, X-Request-Id, x-dingtalk-trace-id)
- Redesign PrintHuman with three verbosity levels (Normal/Verbose/Debug):
  Normal shows trace ID + server code; Verbose adds technical detail;
  Debug adds internal diagnostics (RPC code, operation, reason)
- Add PrintHumanAt for explicit verbosity control, PrintHuman defaults to Normal
- Enhance local logging with request body (sanitized), response body (on error),
  retry attempts, and error classification events
- Add TruncateBody, SanitizeArguments, RedactHeaders logging utilities with
  sensitive key detection via substring matching
- Fix respRetryAfter called before nil guard in doWithRetry retry loop
- Use GetBool instead of string comparison for flag resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:01:41 +08:00

243 lines
6.1 KiB
Go

package errors
import (
"bytes"
"encoding/json"
stderrors "errors"
"fmt"
"strings"
"testing"
)
func TestError_Unwrap_NilCause(t *testing.T) {
t.Parallel()
e := &Error{Category: CategoryInternal, Message: "test"}
if e.Unwrap() != nil {
t.Fatal("expected nil cause")
}
}
func TestError_Unwrap_WithCause(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("root cause")
e := &Error{Category: CategoryInternal, Message: "wrapper", Cause: cause}
if e.Unwrap() != cause {
t.Fatalf("expected cause, got %v", e.Unwrap())
}
}
func TestWithCause_Option(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("underlying")
e := NewAPI("api error", WithCause(cause))
var appErr *Error
if !stderrors.As(e, &appErr) {
t.Fatal("expected *Error type")
}
if appErr.Cause != cause {
t.Fatal("missing cause")
}
}
func TestErrorsIs_ChainTraversal(t *testing.T) {
t.Parallel()
sentinel := fmt.Errorf("sentinel")
e := &Error{Category: CategoryAuth, Message: "auth failed", Cause: sentinel}
if !stderrors.Is(e, sentinel) {
t.Fatal("errors.Is should find sentinel through Unwrap")
}
}
func TestPrintJSON_WithCause(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("db connection failed")
e := &Error{Category: CategoryInternal, Message: "save failed", Cause: cause}
var buf bytes.Buffer
PrintJSON(&buf, e)
if !strings.Contains(buf.String(), "db connection failed") {
t.Fatalf("expected cause in JSON output: %s", buf.String())
}
}
func TestPrintJSON_WithoutCause(t *testing.T) {
t.Parallel()
e := &Error{Category: CategoryAuth, Message: "unauthorized"}
var buf bytes.Buffer
PrintJSON(&buf, e)
if strings.Contains(buf.String(), `"cause"`) {
t.Fatalf("should not contain cause key: %s", buf.String())
}
}
func TestPrintJSON_AllFields(t *testing.T) {
t.Parallel()
rpcData := json.RawMessage(`{"detail":"oops"}`)
e := NewAPI("full error",
WithOperation("tools/call"),
WithReason("timeout"),
WithServerKey("doc"),
WithHint("retry later"),
WithRetryable(true),
WithActions("action1", "action2"),
WithSnapshot("/tmp/snap.json"),
WithRPCCode(-32600),
WithRPCData(rpcData),
WithCause(fmt.Errorf("root")),
)
var buf bytes.Buffer
PrintJSON(&buf, e)
out := buf.String()
for _, expected := range []string{"timeout", "tools/call", "doc", "retry later", "action1", "snap.json", "-32600", "oops", "root"} {
if !strings.Contains(out, expected) {
t.Fatalf("missing %q in output: %s", expected, out)
}
}
}
func TestPrintHuman_WithCause_Verbose(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("timeout")
e := &Error{Category: CategoryDiscovery, Message: "discovery failed", Cause: cause}
var buf bytes.Buffer
PrintHumanAt(&buf, e, VerbosityVerbose)
if !strings.Contains(buf.String(), "timeout") {
t.Fatalf("expected cause in verbose human output: %s", buf.String())
}
}
func TestPrintHuman_WithCause_NormalHidesCause(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("timeout")
e := &Error{Category: CategoryDiscovery, Message: "discovery failed", Cause: cause}
var buf bytes.Buffer
PrintHuman(&buf, e)
if strings.Contains(buf.String(), "Cause:") {
t.Fatalf("normal mode should not show Cause: %s", buf.String())
}
}
func TestPrintHuman_AllFields_Debug(t *testing.T) {
t.Parallel()
e := NewAPI("api error",
WithOperation("initialize"),
WithReason("connection_refused"),
WithServerKey("doc"),
WithHint("check network"),
WithRetryable(true),
WithActions("run again"),
WithSnapshot("/tmp/snap"),
WithRPCCode(-32601),
WithRPCData(json.RawMessage(`{"x":1}`)),
WithCause(fmt.Errorf("network")),
)
var buf bytes.Buffer
PrintHumanAt(&buf, e, VerbosityDebug)
out := buf.String()
for _, expected := range []string{"API", "initialize", "connection_refused", "doc", "check network", "run again", "snap", "-32601", "network", "Retryable"} {
if !strings.Contains(out, expected) {
t.Fatalf("missing %q in debug human output: %s", expected, out)
}
}
}
func TestPrintHuman_NilError(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
if err := PrintHuman(&buf, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if buf.Len() != 0 {
t.Fatalf("expected empty output, got: %s", buf.String())
}
}
func TestPrintHuman_PlainError(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
PrintHuman(&buf, fmt.Errorf("plain error"))
if !strings.Contains(buf.String(), "plain error") {
t.Fatalf("expected plain error: %s", buf.String())
}
}
func TestPrintJSON_PlainError(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
PrintJSON(&buf, fmt.Errorf("plain error"))
if !strings.Contains(buf.String(), "plain error") {
t.Fatalf("expected plain error: %s", buf.String())
}
}
func TestExitCode_AllCategories(t *testing.T) {
t.Parallel()
tests := []struct {
cat Category
want int
}{
{CategoryAPI, 1},
{CategoryAuth, 2},
{CategoryValidation, 3},
{CategoryDiscovery, 4},
{CategoryInternal, 5},
}
for _, tt := range tests {
e := &Error{Category: tt.cat}
if got := e.ExitCode(); got != tt.want {
t.Errorf("ExitCode(%s) = %d, want %d", tt.cat, got, tt.want)
}
}
}
func TestExitCode_NonTyped(t *testing.T) {
t.Parallel()
if got := ExitCode(fmt.Errorf("plain")); got != 5 {
t.Fatalf("expected 5, got %d", got)
}
}
func TestWithRPCData_Empty(t *testing.T) {
t.Parallel()
opt := WithRPCData(json.RawMessage(""))
if opt == nil {
t.Fatal("expected non-nil no-op option for empty RPC data")
}
// Should not panic when applied
e := &Error{}
opt(e)
if len(e.RPCData) != 0 {
t.Fatal("empty RPC data option should be a no-op")
}
}
func TestWithActions_Empty(t *testing.T) {
t.Parallel()
e := NewAPI("test", WithActions("", "", ""))
var appErr *Error
stderrors.As(e, &appErr)
if len(appErr.Actions) != 0 {
t.Fatalf("expected no actions, got %v", appErr.Actions)
}
}
func TestSafePath_Valid(t *testing.T) {
t.Parallel()
if err := SafePath("/tmp/test.json"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSafePath_Traversal(t *testing.T) {
t.Parallel()
if err := SafePath("../../etc/passwd"); err == nil {
t.Fatal("expected error for path traversal")
}
}
func TestSafePath_Empty(t *testing.T) {
t.Parallel()
if err := SafePath(""); err == nil {
t.Fatal("expected error for empty path")
}
}