* fix: align smart category args and remove eval fixtures * fix: classify runtime network failures * fix: validate smart category inputs
196 lines
6.3 KiB
Go
196 lines
6.3 KiB
Go
package transport
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
)
|
|
|
|
func TestHTTPStatusErrorIncludesCallMetadata(t *testing.T) {
|
|
err := httpStatusError("tools/call", "https://mcp.dingtalk.com/server", http.StatusTooManyRequests, "", "")
|
|
|
|
var callErr *CallError
|
|
if !errors.As(err, &callErr) {
|
|
t.Fatalf("expected CallError in chain, got %T", err)
|
|
}
|
|
if callErr.Stage != CallStageHTTP {
|
|
t.Fatalf("Stage = %q, want %q", callErr.Stage, CallStageHTTP)
|
|
}
|
|
if callErr.HTTPStatus != http.StatusTooManyRequests {
|
|
t.Fatalf("HTTPStatus = %d, want %d", callErr.HTTPStatus, http.StatusTooManyRequests)
|
|
}
|
|
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) {
|
|
t.Fatalf("expected structured errors.Error, got %T", err)
|
|
}
|
|
if typed.Reason != "http_429" {
|
|
t.Fatalf("Reason = %q, want http_429", typed.Reason)
|
|
}
|
|
}
|
|
|
|
func TestJSONRPCEnvelopeErrorIncludesCallMetadata(t *testing.T) {
|
|
err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32602, Message: "invalid params"}, "", "")
|
|
|
|
var callErr *CallError
|
|
if !errors.As(err, &callErr) {
|
|
t.Fatalf("expected CallError in chain, got %T", err)
|
|
}
|
|
if callErr.Stage != CallStageJSONRPC {
|
|
t.Fatalf("Stage = %q, want %q", callErr.Stage, CallStageJSONRPC)
|
|
}
|
|
if callErr.RPCCode != -32602 {
|
|
t.Fatalf("RPCCode = %d, want -32602", callErr.RPCCode)
|
|
}
|
|
}
|
|
|
|
func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) {
|
|
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return nil, errors.New("dial tcp: connection refused")
|
|
})})
|
|
client.MaxRetries = 0
|
|
|
|
_, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`), "tools/call")
|
|
if err == nil {
|
|
t.Fatal("doWithRetry() error = nil, want request failure")
|
|
}
|
|
|
|
var callErr *CallError
|
|
if !errors.As(err, &callErr) {
|
|
t.Fatalf("expected CallError in chain, got %T", err)
|
|
}
|
|
if callErr.Stage != CallStageRequest {
|
|
t.Fatalf("Stage = %q, want %q", callErr.Stage, CallStageRequest)
|
|
}
|
|
}
|
|
|
|
func TestCallToolRequestFailureUsesAPIClassification(t *testing.T) {
|
|
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return nil, errors.New("dial tcp: connection refused")
|
|
})})
|
|
client.MaxRetries = 0
|
|
|
|
_, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", map[string]any{"keyword": "marker"})
|
|
if err == nil {
|
|
t.Fatal("CallTool() error = nil, want request failure")
|
|
}
|
|
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) {
|
|
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
|
|
}
|
|
if typed.Category != apperrors.CategoryAPI {
|
|
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI)
|
|
}
|
|
if typed.Operation != "tools/call" {
|
|
t.Fatalf("Operation = %q, want tools/call", typed.Operation)
|
|
}
|
|
if typed.Reason != "connection_refused" {
|
|
t.Fatalf("Reason = %q, want connection_refused", typed.Reason)
|
|
}
|
|
actions := strings.Join(typed.Actions, "\n")
|
|
if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") {
|
|
t.Fatalf("runtime network failure contains discovery-only actions: %q", actions)
|
|
}
|
|
}
|
|
|
|
func TestCallToolRetryCancellationUsesAPIClassification(t *testing.T) {
|
|
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: http.NoBody}, nil
|
|
})})
|
|
client.MaxRetries = 1
|
|
client.sleep = func(context.Context, time.Duration) error { return context.Canceled }
|
|
|
|
_, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", nil)
|
|
if err == nil {
|
|
t.Fatal("CallTool() error = nil, want retry cancellation")
|
|
}
|
|
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) {
|
|
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
|
|
}
|
|
if typed.Category != apperrors.CategoryAPI {
|
|
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI)
|
|
}
|
|
if typed.Operation != "tools/call" {
|
|
t.Fatalf("Operation = %q, want tools/call", typed.Operation)
|
|
}
|
|
if typed.Reason != "request_cancelled" {
|
|
t.Fatalf("Reason = %q, want request_cancelled", typed.Reason)
|
|
}
|
|
actions := strings.Join(typed.Actions, "\n")
|
|
if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") {
|
|
t.Fatalf("runtime retry cancellation contains discovery-only actions: %q", actions)
|
|
}
|
|
}
|
|
|
|
func TestDiscoveryRequestFailuresKeepDiscoveryClassification(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
failure error
|
|
operation string
|
|
wantReason string
|
|
call func(*Client) error
|
|
}{
|
|
{
|
|
name: "initialize connection refused",
|
|
failure: errors.New("dial tcp: connection refused"),
|
|
operation: "initialize",
|
|
wantReason: "connection_refused",
|
|
call: func(client *Client) error {
|
|
_, err := client.Initialize(context.Background(), "https://mcp.dingtalk.com/server")
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "tools list dns failure",
|
|
failure: errors.New("dial tcp: lookup mcp.dingtalk.com: no such host"),
|
|
operation: "tools/list",
|
|
wantReason: "dns_resolution_failed",
|
|
call: func(client *Client) error {
|
|
_, err := client.ListTools(context.Background(), "https://mcp.dingtalk.com/server")
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return nil, tt.failure
|
|
})})
|
|
client.MaxRetries = 0
|
|
|
|
err := tt.call(client)
|
|
if err == nil {
|
|
t.Fatal("request error = nil, want failure")
|
|
}
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) {
|
|
t.Fatalf("request error = %T, want *errors.Error", err)
|
|
}
|
|
if typed.Category != apperrors.CategoryDiscovery {
|
|
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryDiscovery)
|
|
}
|
|
if typed.Operation != tt.operation {
|
|
t.Fatalf("Operation = %q, want %q", typed.Operation, tt.operation)
|
|
}
|
|
if typed.Reason != tt.wantReason {
|
|
t.Fatalf("Reason = %q, want %q", typed.Reason, tt.wantReason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|