github.com/qiniu/go-sdk/v7@v7.27.0/sandbox/client_test.go (about)

     1  package sandbox
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"maps"
     9  	"net"
    10  	"net/http"
    11  	"net/http/httptest"
    12  	"slices"
    13  	"strings"
    14  	"sync/atomic"
    15  	"testing"
    16  	"time"
    17  
    18  	"github.com/qiniu/go-sdk/v7/auth"
    19  	"github.com/qiniu/go-sdk/v7/reqid"
    20  	"github.com/qiniu/go-sdk/v7/sandbox/internal/apis"
    21  )
    22  
    23  // mockAPI 实现 apis.ClientWithResponsesInterface 用于测试。
    24  // 每个方法字段可按测试设置;未设置的方法会 panic。
    25  type mockAPI struct {
    26  	createSandboxFn            func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error)
    27  	getSandboxFn               func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error)
    28  	deleteSandboxFn            func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.DeleteSandboxResponse, error)
    29  	listSandboxesFn            func(ctx context.Context, params *apis.ListSandboxesParams, editors ...apis.RequestEditorFn) (*apis.ListSandboxesResponse, error)
    30  	listSandboxesV2Fn          func(ctx context.Context, params *apis.ListSandboxesV2Params, editors ...apis.RequestEditorFn) (*apis.ListSandboxesV2Response, error)
    31  	connectSandboxFn           func(ctx context.Context, sandboxID apis.SandboxID, body apis.ConnectSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.ConnectSandboxResponse, error)
    32  	getSandboxInjectionsFn     func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxInjectionsResponse, error)
    33  	updateSandboxInjectionsFn  func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxInjectionsJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxInjectionsResponse, error)
    34  	updateSandboxGithubTokenFn func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxGithubTokenJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxGithubTokenResponse, error)
    35  	updateSandboxTimeoutFn     func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxTimeoutJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxTimeoutResponse, error)
    36  	pauseSandboxFn             func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.PauseSandboxResponse, error)
    37  	getSandboxMetricsFn        func(ctx context.Context, sandboxID apis.SandboxID, params *apis.GetSandboxMetricsParams, editors ...apis.RequestEditorFn) (*apis.GetSandboxMetricsResponse, error)
    38  	getSandboxLogsFn           func(ctx context.Context, sandboxID apis.SandboxID, params *apis.GetSandboxLogsParams, editors ...apis.RequestEditorFn) (*apis.GetSandboxLogsResponse, error)
    39  	refreshSandboxFn           func(ctx context.Context, sandboxID apis.SandboxID, body apis.RefreshSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.RefreshSandboxResponse, error)
    40  	listDefaultTemplatesFn     func(ctx context.Context, editors ...apis.RequestEditorFn) (*apis.ListDefaultTemplatesResponse, error)
    41  	listTemplatesFn            func(ctx context.Context, params *apis.ListTemplatesParams, editors ...apis.RequestEditorFn) (*apis.ListTemplatesResponse, error)
    42  	createTemplateV3Fn         func(ctx context.Context, body apis.CreateTemplateV3JSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateTemplateV3Response, error)
    43  	rebuildTemplateFn          func(ctx context.Context, templateID apis.TemplateID, body apis.RebuildTemplateJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.RebuildTemplateResponse, error)
    44  	getTemplateFn              func(ctx context.Context, templateID apis.TemplateID, params *apis.GetTemplateParams, editors ...apis.RequestEditorFn) (*apis.GetTemplateResponse, error)
    45  	deleteTemplateFn           func(ctx context.Context, templateID apis.TemplateID, editors ...apis.RequestEditorFn) (*apis.DeleteTemplateResponse, error)
    46  	getTemplateBuildStatusFn   func(ctx context.Context, templateID apis.TemplateID, buildID apis.BuildID, params *apis.GetTemplateBuildStatusParams, editors ...apis.RequestEditorFn) (*apis.GetTemplateBuildStatusResponse, error)
    47  	getTemplateByAliasFn       func(ctx context.Context, alias string, editors ...apis.RequestEditorFn) (*apis.GetTemplateByAliasResponse, error)
    48  }
    49  
    50  func httpResponse(statusCode int) *http.Response {
    51  	return &http.Response{StatusCode: statusCode, Header: http.Header{}}
    52  }
    53  
    54  func httpResponseWithReqid(statusCode int, reqidVal string) *http.Response {
    55  	h := http.Header{}
    56  	h.Set("X-Reqid", reqidVal)
    57  	return &http.Response{StatusCode: statusCode, Header: h}
    58  }
    59  
    60  // --- 沙箱操作 ---
    61  
    62  func (m *mockAPI) CreateSandboxWithResponse(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
    63  	return m.createSandboxFn(ctx, params, body, editors...)
    64  }
    65  
    66  func (m *mockAPI) CreateSandboxWithBodyWithResponse(ctx context.Context, params *apis.CreateSandboxParams, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
    67  	panic("not implemented")
    68  }
    69  
    70  func (m *mockAPI) GetSandboxWithResponse(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
    71  	return m.getSandboxFn(ctx, sandboxID, editors...)
    72  }
    73  
    74  func (m *mockAPI) DeleteSandboxWithResponse(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.DeleteSandboxResponse, error) {
    75  	return m.deleteSandboxFn(ctx, sandboxID, editors...)
    76  }
    77  
    78  func (m *mockAPI) ListSandboxesWithResponse(ctx context.Context, params *apis.ListSandboxesParams, editors ...apis.RequestEditorFn) (*apis.ListSandboxesResponse, error) {
    79  	return m.listSandboxesFn(ctx, params, editors...)
    80  }
    81  
    82  func (m *mockAPI) ListSandboxesV2WithResponse(ctx context.Context, params *apis.ListSandboxesV2Params, editors ...apis.RequestEditorFn) (*apis.ListSandboxesV2Response, error) {
    83  	return m.listSandboxesV2Fn(ctx, params, editors...)
    84  }
    85  
    86  func (m *mockAPI) ConnectSandboxWithResponse(ctx context.Context, sandboxID apis.SandboxID, body apis.ConnectSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.ConnectSandboxResponse, error) {
    87  	return m.connectSandboxFn(ctx, sandboxID, body, editors...)
    88  }
    89  
    90  func (m *mockAPI) ConnectSandboxWithBodyWithResponse(ctx context.Context, sandboxID apis.SandboxID, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.ConnectSandboxResponse, error) {
    91  	panic("not implemented")
    92  }
    93  
    94  func (m *mockAPI) GetSandboxInjectionsWithResponse(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxInjectionsResponse, error) {
    95  	return m.getSandboxInjectionsFn(ctx, sandboxID, editors...)
    96  }
    97  
    98  func (m *mockAPI) UpdateSandboxInjectionsWithResponse(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxInjectionsJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxInjectionsResponse, error) {
    99  	return m.updateSandboxInjectionsFn(ctx, sandboxID, body, editors...)
   100  }
   101  
   102  func (m *mockAPI) UpdateSandboxInjectionsWithBodyWithResponse(ctx context.Context, sandboxID apis.SandboxID, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxInjectionsResponse, error) {
   103  	panic("not implemented")
   104  }
   105  
   106  func (m *mockAPI) UpdateSandboxGithubTokenWithResponse(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxGithubTokenJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxGithubTokenResponse, error) {
   107  	return m.updateSandboxGithubTokenFn(ctx, sandboxID, body, editors...)
   108  }
   109  
   110  func (m *mockAPI) UpdateSandboxGithubTokenWithBodyWithResponse(ctx context.Context, sandboxID apis.SandboxID, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxGithubTokenResponse, error) {
   111  	panic("not implemented")
   112  }
   113  
   114  func (m *mockAPI) UpdateSandboxTimeoutWithResponse(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxTimeoutJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxTimeoutResponse, error) {
   115  	return m.updateSandboxTimeoutFn(ctx, sandboxID, body, editors...)
   116  }
   117  
   118  func (m *mockAPI) UpdateSandboxTimeoutWithBodyWithResponse(ctx context.Context, sandboxID apis.SandboxID, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxTimeoutResponse, error) {
   119  	panic("not implemented")
   120  }
   121  
   122  func (m *mockAPI) PauseSandboxWithResponse(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.PauseSandboxResponse, error) {
   123  	return m.pauseSandboxFn(ctx, sandboxID, editors...)
   124  }
   125  
   126  func (m *mockAPI) ResumeSandboxWithResponse(ctx context.Context, sandboxID apis.SandboxID, body apis.ResumeSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.ResumeSandboxResponse, error) {
   127  	panic("not implemented")
   128  }
   129  
   130  func (m *mockAPI) ResumeSandboxWithBodyWithResponse(ctx context.Context, sandboxID apis.SandboxID, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.ResumeSandboxResponse, error) {
   131  	panic("not implemented")
   132  }
   133  
   134  func (m *mockAPI) GetSandboxMetricsWithResponse(ctx context.Context, sandboxID apis.SandboxID, params *apis.GetSandboxMetricsParams, editors ...apis.RequestEditorFn) (*apis.GetSandboxMetricsResponse, error) {
   135  	return m.getSandboxMetricsFn(ctx, sandboxID, params, editors...)
   136  }
   137  
   138  func (m *mockAPI) GetSandboxLogsWithResponse(ctx context.Context, sandboxID apis.SandboxID, params *apis.GetSandboxLogsParams, editors ...apis.RequestEditorFn) (*apis.GetSandboxLogsResponse, error) {
   139  	return m.getSandboxLogsFn(ctx, sandboxID, params, editors...)
   140  }
   141  
   142  func (m *mockAPI) RefreshSandboxWithResponse(ctx context.Context, sandboxID apis.SandboxID, body apis.RefreshSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.RefreshSandboxResponse, error) {
   143  	return m.refreshSandboxFn(ctx, sandboxID, body, editors...)
   144  }
   145  
   146  func (m *mockAPI) RefreshSandboxWithBodyWithResponse(ctx context.Context, sandboxID apis.SandboxID, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.RefreshSandboxResponse, error) {
   147  	panic("not implemented")
   148  }
   149  
   150  func (m *mockAPI) GetSandboxesMetricsWithResponse(ctx context.Context, params *apis.GetSandboxesMetricsParams, editors ...apis.RequestEditorFn) (*apis.GetSandboxesMetricsResponse, error) {
   151  	panic("not implemented")
   152  }
   153  
   154  // --- 模板操作 ---
   155  
   156  func (m *mockAPI) ListDefaultTemplatesWithResponse(ctx context.Context, editors ...apis.RequestEditorFn) (*apis.ListDefaultTemplatesResponse, error) {
   157  	return m.listDefaultTemplatesFn(ctx, editors...)
   158  }
   159  
   160  func (m *mockAPI) ListTemplatesWithResponse(ctx context.Context, params *apis.ListTemplatesParams, editors ...apis.RequestEditorFn) (*apis.ListTemplatesResponse, error) {
   161  	return m.listTemplatesFn(ctx, params, editors...)
   162  }
   163  
   164  func (m *mockAPI) CreateTemplateV3WithResponse(ctx context.Context, body apis.CreateTemplateV3JSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateTemplateV3Response, error) {
   165  	return m.createTemplateV3Fn(ctx, body, editors...)
   166  }
   167  
   168  func (m *mockAPI) CreateTemplateV3WithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.CreateTemplateV3Response, error) {
   169  	panic("not implemented")
   170  }
   171  
   172  func (m *mockAPI) GetTemplateWithResponse(ctx context.Context, templateID apis.TemplateID, params *apis.GetTemplateParams, editors ...apis.RequestEditorFn) (*apis.GetTemplateResponse, error) {
   173  	return m.getTemplateFn(ctx, templateID, params, editors...)
   174  }
   175  
   176  func (m *mockAPI) DeleteTemplateWithResponse(ctx context.Context, templateID apis.TemplateID, editors ...apis.RequestEditorFn) (*apis.DeleteTemplateResponse, error) {
   177  	return m.deleteTemplateFn(ctx, templateID, editors...)
   178  }
   179  
   180  func (m *mockAPI) UpdateTemplateWithResponse(ctx context.Context, templateID apis.TemplateID, body apis.UpdateTemplateJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateTemplateResponse, error) {
   181  	panic("not implemented")
   182  }
   183  
   184  func (m *mockAPI) UpdateTemplateWithBodyWithResponse(ctx context.Context, templateID apis.TemplateID, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.UpdateTemplateResponse, error) {
   185  	panic("not implemented")
   186  }
   187  
   188  func (m *mockAPI) RebuildTemplateWithResponse(ctx context.Context, templateID apis.TemplateID, body apis.RebuildTemplateJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.RebuildTemplateResponse, error) {
   189  	if m.rebuildTemplateFn != nil {
   190  		return m.rebuildTemplateFn(ctx, templateID, body, editors...)
   191  	}
   192  	panic("not implemented")
   193  }
   194  
   195  func (m *mockAPI) RebuildTemplateWithBodyWithResponse(ctx context.Context, templateID apis.TemplateID, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.RebuildTemplateResponse, error) {
   196  	panic("not implemented")
   197  }
   198  
   199  func (m *mockAPI) CreateTemplateWithResponse(ctx context.Context, body apis.CreateTemplateJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateTemplateResponse, error) {
   200  	panic("not implemented")
   201  }
   202  
   203  func (m *mockAPI) CreateTemplateWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.CreateTemplateResponse, error) {
   204  	panic("not implemented")
   205  }
   206  
   207  func (m *mockAPI) CreateTemplateV2WithResponse(ctx context.Context, body apis.CreateTemplateV2JSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateTemplateV2Response, error) {
   208  	panic("not implemented")
   209  }
   210  
   211  func (m *mockAPI) CreateTemplateV2WithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.CreateTemplateV2Response, error) {
   212  	panic("not implemented")
   213  }
   214  
   215  func (m *mockAPI) GetTemplateBuildStatusWithResponse(ctx context.Context, templateID apis.TemplateID, buildID apis.BuildID, params *apis.GetTemplateBuildStatusParams, editors ...apis.RequestEditorFn) (*apis.GetTemplateBuildStatusResponse, error) {
   216  	return m.getTemplateBuildStatusFn(ctx, templateID, buildID, params, editors...)
   217  }
   218  
   219  func (m *mockAPI) GetTemplateBuildLogsWithResponse(ctx context.Context, templateID apis.TemplateID, buildID apis.BuildID, params *apis.GetTemplateBuildLogsParams, editors ...apis.RequestEditorFn) (*apis.GetTemplateBuildLogsResponse, error) {
   220  	panic("not implemented")
   221  }
   222  
   223  func (m *mockAPI) StartTemplateBuildWithResponse(ctx context.Context, templateID apis.TemplateID, buildID apis.BuildID, editors ...apis.RequestEditorFn) (*apis.StartTemplateBuildResponse, error) {
   224  	panic("not implemented")
   225  }
   226  
   227  func (m *mockAPI) StartTemplateBuildV2WithResponse(ctx context.Context, templateID apis.TemplateID, buildID apis.BuildID, body apis.StartTemplateBuildV2JSONRequestBody, editors ...apis.RequestEditorFn) (*apis.StartTemplateBuildV2Response, error) {
   228  	panic("not implemented")
   229  }
   230  
   231  func (m *mockAPI) StartTemplateBuildV2WithBodyWithResponse(ctx context.Context, templateID apis.TemplateID, buildID apis.BuildID, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.StartTemplateBuildV2Response, error) {
   232  	panic("not implemented")
   233  }
   234  
   235  func (m *mockAPI) GetTemplateFilesWithResponse(ctx context.Context, templateID apis.TemplateID, hash string, editors ...apis.RequestEditorFn) (*apis.GetTemplateFilesResponse, error) {
   236  	panic("not implemented")
   237  }
   238  
   239  func (m *mockAPI) GetTemplateByAliasWithResponse(ctx context.Context, alias string, editors ...apis.RequestEditorFn) (*apis.GetTemplateByAliasResponse, error) {
   240  	return m.getTemplateByAliasFn(ctx, alias, editors...)
   241  }
   242  
   243  func (m *mockAPI) AssignTemplateTagsWithResponse(ctx context.Context, body apis.AssignTemplateTagsJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.AssignTemplateTagsResponse, error) {
   244  	panic("not implemented")
   245  }
   246  
   247  func (m *mockAPI) AssignTemplateTagsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.AssignTemplateTagsResponse, error) {
   248  	panic("not implemented")
   249  }
   250  
   251  func (m *mockAPI) DeleteTemplateTagsWithResponse(ctx context.Context, body apis.DeleteTemplateTagsJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.DeleteTemplateTagsResponse, error) {
   252  	panic("not implemented")
   253  }
   254  
   255  func (m *mockAPI) DeleteTemplateTagsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.DeleteTemplateTagsResponse, error) {
   256  	panic("not implemented")
   257  }
   258  
   259  // --- 注入规则操作 ---
   260  
   261  func (m *mockAPI) GetInjectionRulesWithResponse(ctx context.Context, editors ...apis.RequestEditorFn) (*apis.GetInjectionRulesResponse, error) {
   262  	panic("not implemented")
   263  }
   264  
   265  func (m *mockAPI) PostInjectionRulesWithResponse(ctx context.Context, body apis.PostInjectionRulesJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.PostInjectionRulesResponse, error) {
   266  	panic("not implemented")
   267  }
   268  
   269  func (m *mockAPI) PostInjectionRulesWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.PostInjectionRulesResponse, error) {
   270  	panic("not implemented")
   271  }
   272  
   273  func (m *mockAPI) DeleteInjectionRulesRuleIDWithResponse(ctx context.Context, ruleID apis.InjectionRuleID, editors ...apis.RequestEditorFn) (*apis.DeleteInjectionRulesRuleIDResponse, error) {
   274  	panic("not implemented")
   275  }
   276  
   277  func (m *mockAPI) GetInjectionRulesRuleIDWithResponse(ctx context.Context, ruleID apis.InjectionRuleID, editors ...apis.RequestEditorFn) (*apis.GetInjectionRulesRuleIDResponse, error) {
   278  	panic("not implemented")
   279  }
   280  
   281  func (m *mockAPI) PutInjectionRulesRuleIDWithResponse(ctx context.Context, ruleID apis.InjectionRuleID, body apis.PutInjectionRulesRuleIDJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.PutInjectionRulesRuleIDResponse, error) {
   282  	panic("not implemented")
   283  }
   284  
   285  func (m *mockAPI) PutInjectionRulesRuleIDWithBodyWithResponse(ctx context.Context, ruleID apis.InjectionRuleID, contentType string, body io.Reader, editors ...apis.RequestEditorFn) (*apis.PutInjectionRulesRuleIDResponse, error) {
   286  	panic("not implemented")
   287  }
   288  
   289  // ============================================================
   290  // 测试用例
   291  // ============================================================
   292  
   293  func newTestClient(api apis.ClientWithResponsesInterface) *Client {
   294  	maxRetries := 5
   295  	return &Client{config: &Config{
   296  		APIKey:       "test-key",
   297  		RetryMax:     &maxRetries,
   298  		RetryBackoff: func(attempt int) time.Duration { return 0 },
   299  	}, api: api}
   300  }
   301  
   302  func newTestSandbox(c *Client, id string) *Sandbox {
   303  	return &Sandbox{sandboxID: id, client: c}
   304  }
   305  
   306  // --- 客户端测试 ---
   307  
   308  func TestNewClient(t *testing.T) {
   309  	c, err := NewClient(&Config{APIKey: "test-key"})
   310  	if err != nil {
   311  		t.Fatalf("unexpected error: %v", err)
   312  	}
   313  	if c == nil {
   314  		t.Fatal("expected non-nil client")
   315  	}
   316  	if c.api == nil {
   317  		t.Error("expected non-nil API client")
   318  	}
   319  }
   320  
   321  func TestNewClientDefaultEndpoint(t *testing.T) {
   322  	c, err := NewClient(&Config{APIKey: "test-key"})
   323  	if err != nil {
   324  		t.Fatalf("unexpected error: %v", err)
   325  	}
   326  	if c.config.Endpoint != "" {
   327  		t.Error("expected empty endpoint in config (defaults applied internally)")
   328  	}
   329  }
   330  
   331  func TestNewClientCustomEndpoint(t *testing.T) {
   332  	c, err := NewClient(&Config{APIKey: "test-key", Endpoint: "https://custom.example.com"})
   333  	if err != nil {
   334  		t.Fatalf("unexpected error: %v", err)
   335  	}
   336  	if c.config.Endpoint != "https://custom.example.com" {
   337  		t.Errorf("expected custom endpoint, got %q", c.config.Endpoint)
   338  	}
   339  }
   340  
   341  func TestAPIKeyEditor(t *testing.T) {
   342  	editor := apiKeyEditor("test-key")
   343  	req, _ := http.NewRequest("GET", "https://example.com", nil)
   344  	if err := editor(context.Background(), req); err != nil {
   345  		t.Fatalf("unexpected error: %v", err)
   346  	}
   347  	if got := req.Header.Get("X-API-Key"); got != "test-key" {
   348  		t.Errorf("expected X-API-Key 'test-key', got %q", got)
   349  	}
   350  	if got := req.Header.Get("Authorization"); got != "Bearer test-key" {
   351  		t.Errorf("expected Authorization 'Bearer test-key', got %q", got)
   352  	}
   353  }
   354  
   355  func TestAPIKeyEditorSkipsWhenAuthorizationPresent(t *testing.T) {
   356  	editor := apiKeyEditor("test-key")
   357  	req, _ := http.NewRequest("GET", "https://example.com", nil)
   358  	req.Header.Set("Authorization", "QBox ak:sign")
   359  	if err := editor(context.Background(), req); err != nil {
   360  		t.Fatalf("unexpected error: %v", err)
   361  	}
   362  	if got := req.Header.Get("Authorization"); got != "QBox ak:sign" {
   363  		t.Errorf("expected Authorization preserved, got %q", got)
   364  	}
   365  	if got := req.Header.Get("X-API-Key"); got != "" {
   366  		t.Errorf("expected X-API-Key not set when Authorization present, got %q", got)
   367  	}
   368  }
   369  
   370  // TestAPIKeyEditorCredentialsTakesPrecedence 验证在真实调用顺序下
   371  // (client-level apiKeyEditor 先,per-request Credentials editor 后),
   372  // Credentials 的 Authorization 会覆盖 apiKeyEditor 写入的 Bearer,保持优先级。
   373  func TestAPIKeyEditorCredentialsTakesPrecedence(t *testing.T) {
   374  	apiKey := apiKeyEditor("test-key")
   375  	req, _ := http.NewRequest("GET", "https://example.com", nil)
   376  
   377  	// 1) client-level apiKeyEditor 先执行
   378  	if err := apiKey(context.Background(), req); err != nil {
   379  		t.Fatalf("apiKey editor error: %v", err)
   380  	}
   381  	if got := req.Header.Get("Authorization"); got != "Bearer test-key" {
   382  		t.Fatalf("expected Bearer after apiKey editor, got %q", got)
   383  	}
   384  
   385  	// 2) per-request Credentials editor 后执行(模拟 GetCredentialsOption 返回的 editor)
   386  	credentials := func(ctx context.Context, r *http.Request) error {
   387  		r.Header.Set("Authorization", "Qiniu ak:sig")
   388  		return nil
   389  	}
   390  	if err := credentials(context.Background(), req); err != nil {
   391  		t.Fatalf("credentials editor error: %v", err)
   392  	}
   393  
   394  	if got := req.Header.Get("Authorization"); got != "Qiniu ak:sig" {
   395  		t.Errorf("expected Credentials to override Bearer, got %q", got)
   396  	}
   397  	if got := req.Header.Get("X-API-Key"); got != "test-key" {
   398  		t.Errorf("expected X-API-Key retained for backward compat, got %q", got)
   399  	}
   400  }
   401  
   402  func TestReqidEditor(t *testing.T) {
   403  	editor := reqidEditor()
   404  
   405  	// 有 reqid 时注入
   406  	ctx := reqid.WithReqid(context.Background(), "test-reqid-123")
   407  	req, _ := http.NewRequest("GET", "https://example.com", nil)
   408  	if err := editor(ctx, req); err != nil {
   409  		t.Fatalf("unexpected error: %v", err)
   410  	}
   411  	if got := req.Header.Get("X-Reqid"); got != "test-reqid-123" {
   412  		t.Errorf("expected X-Reqid 'test-reqid-123', got %q", got)
   413  	}
   414  
   415  	// 无 reqid 时不设置
   416  	req2, _ := http.NewRequest("GET", "https://example.com", nil)
   417  	if err := editor(context.Background(), req2); err != nil {
   418  		t.Fatalf("unexpected error: %v", err)
   419  	}
   420  	if got := req2.Header.Get("X-Reqid"); got != "" {
   421  		t.Errorf("expected empty X-Reqid, got %q", got)
   422  	}
   423  }
   424  
   425  // --- Sandbox.Create ---
   426  
   427  func TestCreate(t *testing.T) {
   428  	token := "create-token"
   429  	mock := &mockAPI{
   430  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
   431  			return &apis.CreateSandboxResponse{
   432  				JSON201:      &apis.Sandbox{SandboxID: "sb-123", TemplateID: "tmpl-1", EnvdAccessToken: &token},
   433  				HTTPResponse: httpResponse(201),
   434  			}, nil
   435  		},
   436  	}
   437  	c := newTestClient(mock)
   438  	sb, err := c.Create(context.Background(), CreateParams{TemplateID: "tmpl-1"})
   439  	if err != nil {
   440  		t.Fatalf("unexpected error: %v", err)
   441  	}
   442  	if sb.ID() != "sb-123" {
   443  		t.Errorf("expected sandbox ID 'sb-123', got %q", sb.ID())
   444  	}
   445  	if sb.TemplateID() != "tmpl-1" {
   446  		t.Errorf("expected template ID 'tmpl-1', got %q", sb.TemplateID())
   447  	}
   448  }
   449  
   450  func TestCreateWithKodoResource(t *testing.T) {
   451  	token := "create-token"
   452  	prefix := "datasets/"
   453  	readOnly := true
   454  	mock := &mockAPI{
   455  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
   456  			if len(editors) != 1 {
   457  				t.Fatalf("expected one credentials editor, got %d", len(editors))
   458  			}
   459  			req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://sandbox.example.com/sandboxes", nil)
   460  			if err != nil {
   461  				t.Fatalf("unexpected request error: %v", err)
   462  			}
   463  			if err := editors[0](ctx, req); err != nil {
   464  				t.Fatalf("unexpected credentials editor error: %v", err)
   465  			}
   466  			if got := req.Header.Get("Authorization"); !strings.HasPrefix(got, "Qiniu ") {
   467  				t.Fatalf("expected Qiniu Authorization header, got %q", got)
   468  			}
   469  			if body.Resources == nil || len(*body.Resources) != 1 {
   470  				t.Fatalf("expected one resource, got %#v", body.Resources)
   471  			}
   472  			kodo, err := (*body.Resources)[0].AsKodoResource()
   473  			if err != nil {
   474  				t.Fatalf("expected KodoResource, got error: %v", err)
   475  			}
   476  			if kodo.Type != apis.Kodo {
   477  				t.Errorf("expected resource type %q, got %q", apis.Kodo, kodo.Type)
   478  			}
   479  			if kodo.Bucket != "test-bucket" {
   480  				t.Errorf("expected bucket test-bucket, got %q", kodo.Bucket)
   481  			}
   482  			if kodo.MountPath != "/mnt/kodo" {
   483  				t.Errorf("expected mount path /mnt/kodo, got %q", kodo.MountPath)
   484  			}
   485  			if kodo.Prefix == nil || *kodo.Prefix != prefix {
   486  				t.Errorf("expected prefix %q, got %v", prefix, kodo.Prefix)
   487  			}
   488  			if kodo.ReadOnly == nil || *kodo.ReadOnly != readOnly {
   489  				t.Errorf("expected read_only %v, got %v", readOnly, kodo.ReadOnly)
   490  			}
   491  			return &apis.CreateSandboxResponse{
   492  				JSON201:      &apis.Sandbox{SandboxID: "sb-123", TemplateID: "tmpl-1", EnvdAccessToken: &token},
   493  				HTTPResponse: httpResponse(201),
   494  			}, nil
   495  		},
   496  	}
   497  	c := newTestClient(mock)
   498  	c.config.Credentials = auth.New("ak", "sk")
   499  	_, err := c.Create(context.Background(), CreateParams{
   500  		TemplateID: "tmpl-1",
   501  		Resources: &[]SandboxResourceSpec{
   502  			{
   503  				Kodo: &KodoResource{
   504  					Bucket:    "test-bucket",
   505  					MountPath: "/mnt/kodo",
   506  					Prefix:    &prefix,
   507  					ReadOnly:  &readOnly,
   508  				},
   509  			},
   510  		},
   511  	})
   512  	if err != nil {
   513  		t.Fatalf("unexpected error: %v", err)
   514  	}
   515  }
   516  
   517  func TestCreateWithoutToken(t *testing.T) {
   518  	// Create API 不返回 token,应通过 GetSandbox 补充
   519  	token := "fallback-token"
   520  	mock := &mockAPI{
   521  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
   522  			return &apis.CreateSandboxResponse{
   523  				JSON201:      &apis.Sandbox{SandboxID: "sb-123", TemplateID: "tmpl-1"},
   524  				HTTPResponse: httpResponse(201),
   525  			}, nil
   526  		},
   527  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
   528  			return &apis.GetSandboxResponse{
   529  				JSON200:      &apis.SandboxDetail{SandboxID: sandboxID, EnvdAccessToken: &token},
   530  				HTTPResponse: httpResponse(200),
   531  			}, nil
   532  		},
   533  	}
   534  	c := newTestClient(mock)
   535  	sb, err := c.Create(context.Background(), CreateParams{TemplateID: "tmpl-1"})
   536  	if err != nil {
   537  		t.Fatalf("unexpected error: %v", err)
   538  	}
   539  	if sb.ID() != "sb-123" {
   540  		t.Errorf("expected sandbox ID 'sb-123', got %q", sb.ID())
   541  	}
   542  	sb.envdTokenMu.RLock()
   543  	tok := sb.envdAccessToken
   544  	sb.envdTokenMu.RUnlock()
   545  	if tok == nil || *tok != "fallback-token" {
   546  		t.Errorf("expected envdAccessToken 'fallback-token', got %v", tok)
   547  	}
   548  }
   549  
   550  func TestCreateRefreshTokenError(t *testing.T) {
   551  	// Create API 不返回 token,GetSandbox 也失败 → Create 返回错误
   552  	mock := &mockAPI{
   553  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
   554  			return &apis.CreateSandboxResponse{
   555  				JSON201:      &apis.Sandbox{SandboxID: "sb-123", TemplateID: "tmpl-1"},
   556  				HTTPResponse: httpResponse(201),
   557  			}, nil
   558  		},
   559  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
   560  			return nil, fmt.Errorf("network error")
   561  		},
   562  	}
   563  	c := newTestClient(mock)
   564  	_, err := c.Create(context.Background(), CreateParams{TemplateID: "tmpl-1"})
   565  	if err == nil {
   566  		t.Fatal("expected error when GetSandbox fails")
   567  	}
   568  }
   569  
   570  func TestCreateError(t *testing.T) {
   571  	mock := &mockAPI{
   572  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
   573  			return &apis.CreateSandboxResponse{
   574  				HTTPResponse: httpResponse(400),
   575  				Body:         []byte(`{"message":"bad request"}`),
   576  			}, nil
   577  		},
   578  	}
   579  	c := newTestClient(mock)
   580  	_, err := c.Create(context.Background(), CreateParams{TemplateID: "tmpl-1"})
   581  	if err == nil {
   582  		t.Fatal("expected error")
   583  	}
   584  	apiErr, ok := err.(*APIError)
   585  	if !ok {
   586  		t.Fatalf("expected *APIError, got %T", err)
   587  	}
   588  	if apiErr.StatusCode != 400 {
   589  		t.Errorf("expected status 400, got %d", apiErr.StatusCode)
   590  	}
   591  }
   592  
   593  // --- Sandbox.Connect ---
   594  
   595  func TestConnectWithToken(t *testing.T) {
   596  	// Connect 返回 token 时,不应调用 GetSandbox
   597  	token := "connect-token"
   598  	var getSandboxCalled atomic.Bool
   599  	mock := &mockAPI{
   600  		connectSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.ConnectSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.ConnectSandboxResponse, error) {
   601  			return &apis.ConnectSandboxResponse{
   602  				JSON200:      &apis.Sandbox{SandboxID: sandboxID, TemplateID: "tmpl-1", EnvdAccessToken: &token},
   603  				HTTPResponse: httpResponse(200),
   604  			}, nil
   605  		},
   606  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
   607  			getSandboxCalled.Store(true)
   608  			return &apis.GetSandboxResponse{
   609  				JSON200:      &apis.SandboxDetail{SandboxID: sandboxID},
   610  				HTTPResponse: httpResponse(200),
   611  			}, nil
   612  		},
   613  	}
   614  	c := newTestClient(mock)
   615  	sb, err := c.Connect(context.Background(), "sb-123", ConnectParams{Timeout: 60})
   616  	if err != nil {
   617  		t.Fatalf("unexpected error: %v", err)
   618  	}
   619  	if sb.ID() != "sb-123" {
   620  		t.Errorf("expected sandbox ID 'sb-123', got %q", sb.ID())
   621  	}
   622  	if getSandboxCalled.Load() {
   623  		t.Error("GetSandbox should not be called when Connect returns a token")
   624  	}
   625  	sb.envdTokenMu.RLock()
   626  	tok := sb.envdAccessToken
   627  	sb.envdTokenMu.RUnlock()
   628  	if tok == nil || *tok != "connect-token" {
   629  		t.Errorf("expected envdAccessToken 'connect-token', got %v", tok)
   630  	}
   631  }
   632  
   633  func TestConnectWithoutToken(t *testing.T) {
   634  	// Connect 不返回 token,通过 GetSandbox 获取
   635  	token := "fallback-token"
   636  	mock := &mockAPI{
   637  		connectSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.ConnectSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.ConnectSandboxResponse, error) {
   638  			return &apis.ConnectSandboxResponse{
   639  				JSON200:      &apis.Sandbox{SandboxID: sandboxID, TemplateID: "tmpl-1"},
   640  				HTTPResponse: httpResponse(200),
   641  			}, nil
   642  		},
   643  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
   644  			return &apis.GetSandboxResponse{
   645  				JSON200:      &apis.SandboxDetail{SandboxID: sandboxID, EnvdAccessToken: &token},
   646  				HTTPResponse: httpResponse(200),
   647  			}, nil
   648  		},
   649  	}
   650  	c := newTestClient(mock)
   651  	sb, err := c.Connect(context.Background(), "sb-123", ConnectParams{Timeout: 60})
   652  	if err != nil {
   653  		t.Fatalf("unexpected error: %v", err)
   654  	}
   655  	if sb.ID() != "sb-123" {
   656  		t.Errorf("expected sandbox ID 'sb-123', got %q", sb.ID())
   657  	}
   658  	sb.envdTokenMu.RLock()
   659  	tok := sb.envdAccessToken
   660  	sb.envdTokenMu.RUnlock()
   661  	if tok == nil || *tok != "fallback-token" {
   662  		t.Errorf("expected envdAccessToken 'fallback-token', got %v", tok)
   663  	}
   664  }
   665  
   666  func TestConnectRefreshTokenError(t *testing.T) {
   667  	// Connect 不返回 token,GetSandbox 报错 → Connect 返回错误
   668  	mock := &mockAPI{
   669  		connectSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.ConnectSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.ConnectSandboxResponse, error) {
   670  			return &apis.ConnectSandboxResponse{
   671  				JSON200:      &apis.Sandbox{SandboxID: sandboxID, TemplateID: "tmpl-1"},
   672  				HTTPResponse: httpResponse(200),
   673  			}, nil
   674  		},
   675  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
   676  			return nil, errors.New("network error")
   677  		},
   678  	}
   679  	c := newTestClient(mock)
   680  	_, err := c.Connect(context.Background(), "sb-123", ConnectParams{Timeout: 60})
   681  	if err == nil {
   682  		t.Fatal("expected error when GetSandbox fails")
   683  	}
   684  }
   685  
   686  func TestConnect201Response(t *testing.T) {
   687  	// Connect 返回 201(恢复已暂停的沙箱),带 token
   688  	token := "resumed-token"
   689  	mock := &mockAPI{
   690  		connectSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.ConnectSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.ConnectSandboxResponse, error) {
   691  			return &apis.ConnectSandboxResponse{
   692  				JSON201:      &apis.Sandbox{SandboxID: sandboxID, TemplateID: "tmpl-1", EnvdAccessToken: &token},
   693  				HTTPResponse: httpResponse(201),
   694  			}, nil
   695  		},
   696  	}
   697  	c := newTestClient(mock)
   698  	sb, err := c.Connect(context.Background(), "sb-123", ConnectParams{Timeout: 60})
   699  	if err != nil {
   700  		t.Fatalf("unexpected error: %v", err)
   701  	}
   702  	if sb.ID() != "sb-123" {
   703  		t.Errorf("expected sandbox ID 'sb-123', got %q", sb.ID())
   704  	}
   705  }
   706  
   707  // --- Sandbox.List ---
   708  
   709  func TestList(t *testing.T) {
   710  	mock := &mockAPI{
   711  		listSandboxesV2Fn: func(ctx context.Context, params *apis.ListSandboxesV2Params, editors ...apis.RequestEditorFn) (*apis.ListSandboxesV2Response, error) {
   712  			list := []apis.ListedSandbox{
   713  				{SandboxID: "sb-1"},
   714  				{SandboxID: "sb-2"},
   715  			}
   716  			return &apis.ListSandboxesV2Response{
   717  				JSON200:      &list,
   718  				HTTPResponse: httpResponse(200),
   719  			}, nil
   720  		},
   721  	}
   722  	c := newTestClient(mock)
   723  	sandboxes, err := c.List(context.Background(), nil)
   724  	if err != nil {
   725  		t.Fatalf("unexpected error: %v", err)
   726  	}
   727  	if len(sandboxes) != 2 {
   728  		t.Errorf("expected 2 sandboxes, got %d", len(sandboxes))
   729  	}
   730  }
   731  
   732  func TestListFiltersByTemplates(t *testing.T) {
   733  	templates := []string{"tmpl-1", "qiniu/code-interpreter"}
   734  	mock := &mockAPI{
   735  		listSandboxesV2Fn: func(ctx context.Context, params *apis.ListSandboxesV2Params, editors ...apis.RequestEditorFn) (*apis.ListSandboxesV2Response, error) {
   736  			if params.Template == nil || !slices.Equal(*params.Template, templates) {
   737  				t.Fatalf("expected template filter %v, got %v", templates, params.Template)
   738  			}
   739  			list := []apis.ListedSandbox{}
   740  			return &apis.ListSandboxesV2Response{JSON200: &list, HTTPResponse: httpResponse(200)}, nil
   741  		},
   742  	}
   743  	c := newTestClient(mock)
   744  	_, err := c.List(context.Background(), &ListParams{Template: &templates})
   745  	if err != nil {
   746  		t.Fatalf("unexpected error: %v", err)
   747  	}
   748  }
   749  
   750  func TestGetInjections(t *testing.T) {
   751  	var byID apis.SandboxInjection
   752  	if err := byID.FromInjectionByID(apis.InjectionByID{ID: "rule-1", Type: apis.ID}); err != nil {
   753  		t.Fatalf("create API injection: %v", err)
   754  	}
   755  	maskedHeader := map[string]string{"Authorization": "Bear****"}
   756  	var direct apis.SandboxInjection
   757  	if err := direct.FromHTTPInjection(apis.HTTPInjection{BaseURL: "api.example.com", Headers: &maskedHeader, Type: apis.HTTP}); err != nil {
   758  		t.Fatalf("create direct API injection: %v", err)
   759  	}
   760  	mock := &mockAPI{
   761  		getSandboxInjectionsFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxInjectionsResponse, error) {
   762  			resp := &apis.GetSandboxInjectionsResponse{HTTPResponse: httpResponse(200)}
   763  			resp.JSON200 = &struct {
   764  				Injections []apis.SandboxInjection `json:"injections"`
   765  			}{Injections: []apis.SandboxInjection{byID, direct}}
   766  			return resp, nil
   767  		},
   768  	}
   769  	c := newTestClient(mock)
   770  	sb := newTestSandbox(c, "sb-123")
   771  	injections, err := sb.GetInjections(context.Background())
   772  	if err != nil {
   773  		t.Fatalf("unexpected error: %v", err)
   774  	}
   775  	var _ []MaskedSandboxInjection = injections
   776  	if len(injections) != 2 || injections[0].ByID == nil || *injections[0].ByID != "rule-1" {
   777  		t.Fatalf("unexpected injections: %+v", injections)
   778  	}
   779  	if injections[1].HTTP == nil || injections[1].HTTP.BaseURL != "api.example.com" || injections[1].HTTP.Headers == nil || !maps.Equal(*injections[1].HTTP.Headers, maskedHeader) {
   780  		t.Fatalf("unexpected direct injection: %+v", injections[1])
   781  	}
   782  }
   783  
   784  func TestUpdateInjections(t *testing.T) {
   785  	ruleID := "rule-1"
   786  	headers := map[string]string{"Authorization": "Bearer token"}
   787  	mock := &mockAPI{
   788  		updateSandboxInjectionsFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxInjectionsJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxInjectionsResponse, error) {
   789  			if sandboxID != "sb-123" {
   790  				t.Fatalf("unexpected sandbox ID: %s", sandboxID)
   791  			}
   792  			if len(body.Injections) != 2 {
   793  				t.Fatalf("expected 2 injections, got %d", len(body.Injections))
   794  			}
   795  			byID, err := body.Injections[0].AsInjectionByID()
   796  			if err != nil || byID.ID != ruleID {
   797  				t.Fatalf("unexpected injection: %+v, err: %v", byID, err)
   798  			}
   799  			httpInjection, err := body.Injections[1].AsHTTPInjection()
   800  			if err != nil || httpInjection.BaseURL != "https://api.example.com" || httpInjection.Headers == nil || !maps.Equal(*httpInjection.Headers, headers) {
   801  				t.Fatalf("unexpected HTTP injection: %+v, err: %v", httpInjection, err)
   802  			}
   803  			return &apis.UpdateSandboxInjectionsResponse{HTTPResponse: httpResponse(204)}, nil
   804  		},
   805  	}
   806  	c := newTestClient(mock)
   807  	sb := newTestSandbox(c, "sb-123")
   808  	if err := sb.UpdateInjections(context.Background(), []SandboxInjectionSpec{
   809  		{ByID: &ruleID},
   810  		{HTTP: &HTTPInjection{BaseURL: "https://api.example.com", Headers: &headers}},
   811  	}); err != nil {
   812  		t.Fatalf("unexpected error: %v", err)
   813  	}
   814  }
   815  
   816  func TestUpdateInjectionsClearsAllInjections(t *testing.T) {
   817  	mock := &mockAPI{
   818  		updateSandboxInjectionsFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxInjectionsJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxInjectionsResponse, error) {
   819  			if body.Injections == nil || len(body.Injections) != 0 {
   820  				t.Fatalf("expected a non-nil empty injections list, got %#v", body.Injections)
   821  			}
   822  			return &apis.UpdateSandboxInjectionsResponse{HTTPResponse: httpResponse(204)}, nil
   823  		},
   824  	}
   825  	c := newTestClient(mock)
   826  	sb := newTestSandbox(c, "sb-123")
   827  	if err := sb.UpdateInjections(context.Background(), []SandboxInjectionSpec{}); err != nil {
   828  		t.Fatalf("unexpected error: %v", err)
   829  	}
   830  }
   831  
   832  func TestGetInjectionsErrors(t *testing.T) {
   833  	t.Run("transport", func(t *testing.T) {
   834  		mock := &mockAPI{
   835  			getSandboxInjectionsFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxInjectionsResponse, error) {
   836  				return nil, errors.New("network error")
   837  			},
   838  		}
   839  		_, err := newTestSandbox(newTestClient(mock), "sb-123").GetInjections(context.Background())
   840  		if err == nil || err.Error() != "network error" {
   841  			t.Fatalf("expected transport error, got %v", err)
   842  		}
   843  	})
   844  
   845  	t.Run("api", func(t *testing.T) {
   846  		mock := &mockAPI{
   847  			getSandboxInjectionsFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxInjectionsResponse, error) {
   848  				return &apis.GetSandboxInjectionsResponse{
   849  					HTTPResponse: httpResponse(404),
   850  					Body:         []byte(`{"message":"sandbox not found"}`),
   851  				}, nil
   852  			},
   853  		}
   854  		_, err := newTestSandbox(newTestClient(mock), "sb-404").GetInjections(context.Background())
   855  		var apiErr *APIError
   856  		if !errors.As(err, &apiErr) || apiErr.StatusCode != 404 {
   857  			t.Fatalf("expected 404 API error, got %v", err)
   858  		}
   859  	})
   860  }
   861  
   862  func TestUpdateInjectionsErrors(t *testing.T) {
   863  	ruleID := "rule-1"
   864  	tests := []struct {
   865  		name string
   866  		fn   func(context.Context, apis.SandboxID, apis.UpdateSandboxInjectionsJSONRequestBody, ...apis.RequestEditorFn) (*apis.UpdateSandboxInjectionsResponse, error)
   867  	}{
   868  		{
   869  			name: "transport",
   870  			fn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxInjectionsJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxInjectionsResponse, error) {
   871  				return nil, errors.New("network error")
   872  			},
   873  		},
   874  		{
   875  			name: "api",
   876  			fn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxInjectionsJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxInjectionsResponse, error) {
   877  				return &apis.UpdateSandboxInjectionsResponse{
   878  					HTTPResponse: httpResponse(409),
   879  					Body:         []byte(`{"message":"sandbox is not running"}`),
   880  				}, nil
   881  			},
   882  		},
   883  	}
   884  	for _, tt := range tests {
   885  		t.Run(tt.name, func(t *testing.T) {
   886  			mock := &mockAPI{updateSandboxInjectionsFn: tt.fn}
   887  			err := newTestSandbox(newTestClient(mock), "sb-123").UpdateInjections(context.Background(), []SandboxInjectionSpec{{ByID: &ruleID}})
   888  			if err == nil {
   889  				t.Fatal("expected error")
   890  			}
   891  		})
   892  	}
   893  }
   894  
   895  func TestUpdateGitHubToken(t *testing.T) {
   896  	mock := &mockAPI{
   897  		updateSandboxGithubTokenFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxGithubTokenJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxGithubTokenResponse, error) {
   898  			if body.AuthorizationToken != "github-token" {
   899  				t.Fatalf("unexpected token: %q", body.AuthorizationToken)
   900  			}
   901  			return &apis.UpdateSandboxGithubTokenResponse{HTTPResponse: httpResponse(204)}, nil
   902  		},
   903  	}
   904  	c := newTestClient(mock)
   905  	sb := newTestSandbox(c, "sb-123")
   906  	if err := sb.UpdateGitHubToken(context.Background(), "github-token"); err != nil {
   907  		t.Fatalf("unexpected error: %v", err)
   908  	}
   909  }
   910  
   911  func TestUpdateGitHubTokenErrors(t *testing.T) {
   912  	tests := []struct {
   913  		name string
   914  		fn   func(context.Context, apis.SandboxID, apis.UpdateSandboxGithubTokenJSONRequestBody, ...apis.RequestEditorFn) (*apis.UpdateSandboxGithubTokenResponse, error)
   915  	}{
   916  		{
   917  			name: "transport",
   918  			fn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxGithubTokenJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxGithubTokenResponse, error) {
   919  				return nil, errors.New("network error")
   920  			},
   921  		},
   922  		{
   923  			name: "api",
   924  			fn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxGithubTokenJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxGithubTokenResponse, error) {
   925  				return &apis.UpdateSandboxGithubTokenResponse{
   926  					HTTPResponse: httpResponse(400),
   927  					Body:         []byte(`{"message":"invalid token"}`),
   928  				}, nil
   929  			},
   930  		},
   931  	}
   932  	for _, tt := range tests {
   933  		t.Run(tt.name, func(t *testing.T) {
   934  			mock := &mockAPI{updateSandboxGithubTokenFn: tt.fn}
   935  			err := newTestSandbox(newTestClient(mock), "sb-123").UpdateGitHubToken(context.Background(), "github-token")
   936  			if err == nil {
   937  				t.Fatal("expected error")
   938  			}
   939  		})
   940  	}
   941  }
   942  
   943  // --- Sandbox.Kill ---
   944  
   945  func TestKill(t *testing.T) {
   946  	mock := &mockAPI{
   947  		deleteSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.DeleteSandboxResponse, error) {
   948  			return &apis.DeleteSandboxResponse{HTTPResponse: httpResponse(204)}, nil
   949  		},
   950  	}
   951  	c := newTestClient(mock)
   952  	sb := newTestSandbox(c, "sb-123")
   953  	if err := sb.Kill(context.Background()); err != nil {
   954  		t.Fatalf("unexpected error: %v", err)
   955  	}
   956  }
   957  
   958  // --- Sandbox.SetTimeout ---
   959  
   960  func TestSetTimeout(t *testing.T) {
   961  	var gotTimeout int32
   962  	mock := &mockAPI{
   963  		updateSandboxTimeoutFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxTimeoutJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxTimeoutResponse, error) {
   964  			gotTimeout = body.Timeout
   965  			return &apis.UpdateSandboxTimeoutResponse{HTTPResponse: httpResponse(204)}, nil
   966  		},
   967  	}
   968  	c := newTestClient(mock)
   969  	sb := newTestSandbox(c, "sb-123")
   970  	if err := sb.SetTimeout(context.Background(), 2*time.Minute); err != nil {
   971  		t.Fatalf("unexpected error: %v", err)
   972  	}
   973  	if gotTimeout != 120 {
   974  		t.Errorf("expected timeout 120 seconds, got %d", gotTimeout)
   975  	}
   976  }
   977  
   978  // --- Sandbox.GetInfo ---
   979  
   980  func TestGetInfo(t *testing.T) {
   981  	mock := &mockAPI{
   982  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
   983  			return &apis.GetSandboxResponse{
   984  				JSON200:      &apis.SandboxDetail{SandboxID: sandboxID, State: apis.Running},
   985  				HTTPResponse: httpResponse(200),
   986  			}, nil
   987  		},
   988  	}
   989  	c := newTestClient(mock)
   990  	sb := newTestSandbox(c, "sb-123")
   991  	info, err := sb.GetInfo(context.Background())
   992  	if err != nil {
   993  		t.Fatalf("unexpected error: %v", err)
   994  	}
   995  	if info.State != StateRunning {
   996  		t.Errorf("expected state 'running', got %q", info.State)
   997  	}
   998  }
   999  
  1000  // --- Sandbox.IsRunning ---
  1001  
  1002  // newEnvdHealthServer 启动一个 mock envd 服务,/health 端点返回指定状态码。
  1003  func newEnvdHealthServer(statusCode int) *httptest.Server {
  1004  	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1005  		if r.URL.Path == "/health" {
  1006  			w.WriteHeader(statusCode)
  1007  			return
  1008  		}
  1009  		w.WriteHeader(http.StatusNotFound)
  1010  	}))
  1011  }
  1012  
  1013  func TestIsRunning(t *testing.T) {
  1014  	ts := newEnvdHealthServer(http.StatusNoContent)
  1015  	defer ts.Close()
  1016  
  1017  	// 构造 sandbox,envdURL() 指向 test server
  1018  	// 需要让 GetHost 返回 test server 的 host,这里直接设置 domain 并覆盖 envdURL
  1019  	c := &Client{config: &Config{APIKey: "test-key", HTTPClient: ts.Client()}}
  1020  	sb := &Sandbox{sandboxID: "sb-123", client: c}
  1021  	// 覆盖 envdURL: 通过设置一个 helper 来让 envdURL 返回 test server URL
  1022  	// 由于 envdURL 依赖 GetHost,我们使用 httptest 的 URL 作为 base
  1023  	// 直接测试: 使用自定义 transport 将请求重定向到 test server
  1024  	c.config.HTTPClient = redirectClient(ts.URL)
  1025  
  1026  	running, err := sb.IsRunning(context.Background())
  1027  	if err != nil {
  1028  		t.Fatalf("unexpected error: %v", err)
  1029  	}
  1030  	if !running {
  1031  		t.Error("expected sandbox to be running")
  1032  	}
  1033  }
  1034  
  1035  func TestIsRunningNotReachable(t *testing.T) {
  1036  	ts := newEnvdHealthServer(http.StatusBadGateway)
  1037  	defer ts.Close()
  1038  
  1039  	c := &Client{config: &Config{APIKey: "test-key", HTTPClient: redirectClient(ts.URL)}}
  1040  	sb := &Sandbox{sandboxID: "sb-123", client: c}
  1041  
  1042  	running, err := sb.IsRunning(context.Background())
  1043  	if err != nil {
  1044  		t.Fatalf("unexpected error: %v", err)
  1045  	}
  1046  	if running {
  1047  		t.Error("expected sandbox to not be running")
  1048  	}
  1049  }
  1050  
  1051  // redirectClient 返回一个 HTTP 客户端,将所有请求重定向到指定的 target URL。
  1052  func redirectClient(target string) *http.Client {
  1053  	return &http.Client{
  1054  		Transport: &redirectTransport{target: target},
  1055  	}
  1056  }
  1057  
  1058  type redirectTransport struct {
  1059  	target string
  1060  }
  1061  
  1062  func (t *redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
  1063  	// 保留原始路径和查询,将 scheme+host 替换为 test server
  1064  	newURL := t.target + req.URL.Path
  1065  	if req.URL.RawQuery != "" {
  1066  		newURL += "?" + req.URL.RawQuery
  1067  	}
  1068  	newReq, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
  1069  	if err != nil {
  1070  		return nil, err
  1071  	}
  1072  	newReq.Header = req.Header
  1073  	return http.DefaultTransport.RoundTrip(newReq)
  1074  }
  1075  
  1076  // --- Sandbox.Pause ---
  1077  
  1078  func TestPause(t *testing.T) {
  1079  	mock := &mockAPI{
  1080  		pauseSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.PauseSandboxResponse, error) {
  1081  			return &apis.PauseSandboxResponse{HTTPResponse: httpResponse(204)}, nil
  1082  		},
  1083  	}
  1084  	c := newTestClient(mock)
  1085  	sb := newTestSandbox(c, "sb-123")
  1086  	if err := sb.Pause(context.Background()); err != nil {
  1087  		t.Fatalf("unexpected error: %v", err)
  1088  	}
  1089  }
  1090  
  1091  // --- Sandbox.WaitForReady ---
  1092  
  1093  func TestWaitForReadyImmediate(t *testing.T) {
  1094  	mock := &mockAPI{
  1095  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
  1096  			return &apis.GetSandboxResponse{
  1097  				JSON200:      &apis.SandboxDetail{SandboxID: sandboxID, State: apis.Running},
  1098  				HTTPResponse: httpResponse(200),
  1099  			}, nil
  1100  		},
  1101  	}
  1102  	c := newTestClient(mock)
  1103  	sb := newTestSandbox(c, "sb-123")
  1104  	info, err := sb.WaitForReady(context.Background(), WithPollInterval(100*time.Millisecond))
  1105  	if err != nil {
  1106  		t.Fatalf("unexpected error: %v", err)
  1107  	}
  1108  	if info.SandboxID != "sb-123" {
  1109  		t.Errorf("expected sandbox ID 'sb-123', got %q", info.SandboxID)
  1110  	}
  1111  }
  1112  
  1113  func TestWaitForReadyTimeout(t *testing.T) {
  1114  	mock := &mockAPI{
  1115  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
  1116  			return &apis.GetSandboxResponse{
  1117  				JSON200:      &apis.SandboxDetail{SandboxID: sandboxID, State: apis.Paused},
  1118  				HTTPResponse: httpResponse(200),
  1119  			}, nil
  1120  		},
  1121  	}
  1122  	c := newTestClient(mock)
  1123  	sb := newTestSandbox(c, "sb-123")
  1124  	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
  1125  	defer cancel()
  1126  	_, err := sb.WaitForReady(ctx, WithPollInterval(50*time.Millisecond))
  1127  	if err == nil {
  1128  		t.Fatal("expected timeout error")
  1129  	}
  1130  }
  1131  
  1132  // --- 模板测试 ---
  1133  
  1134  func TestListDefaultTemplates(t *testing.T) {
  1135  	mock := &mockAPI{
  1136  		listDefaultTemplatesFn: func(ctx context.Context, editors ...apis.RequestEditorFn) (*apis.ListDefaultTemplatesResponse, error) {
  1137  			list := []apis.Template{
  1138  				{TemplateID: "tmpl-default-1", Names: []string{"qiniu/code-interpreter"}},
  1139  				{TemplateID: "tmpl-default-2"},
  1140  			}
  1141  			return &apis.ListDefaultTemplatesResponse{
  1142  				JSON200:      &list,
  1143  				HTTPResponse: httpResponse(200),
  1144  			}, nil
  1145  		},
  1146  	}
  1147  	c := newTestClient(mock)
  1148  	templates, err := c.ListDefaultTemplates(context.Background())
  1149  	if err != nil {
  1150  		t.Fatalf("unexpected error: %v", err)
  1151  	}
  1152  	if len(templates) != 2 {
  1153  		t.Errorf("expected 2 templates, got %d", len(templates))
  1154  	}
  1155  	if !slices.Equal(templates[0].Names, []string{"qiniu/code-interpreter"}) {
  1156  		t.Errorf("unexpected names: %v", templates[0].Names)
  1157  	}
  1158  }
  1159  
  1160  func TestListDefaultTemplatesError(t *testing.T) {
  1161  	mock := &mockAPI{
  1162  		listDefaultTemplatesFn: func(ctx context.Context, editors ...apis.RequestEditorFn) (*apis.ListDefaultTemplatesResponse, error) {
  1163  			return &apis.ListDefaultTemplatesResponse{
  1164  				HTTPResponse: httpResponse(401),
  1165  				Body:         []byte(`{"message":"unauthorized"}`),
  1166  			}, nil
  1167  		},
  1168  	}
  1169  	c := newTestClient(mock)
  1170  	_, err := c.ListDefaultTemplates(context.Background())
  1171  	if err == nil {
  1172  		t.Fatal("expected error")
  1173  	}
  1174  	apiErr, ok := err.(*APIError)
  1175  	if !ok {
  1176  		t.Fatalf("expected *APIError, got %T", err)
  1177  	}
  1178  	if apiErr.StatusCode != 401 {
  1179  		t.Errorf("expected status 401, got %d", apiErr.StatusCode)
  1180  	}
  1181  }
  1182  
  1183  func TestListTemplates(t *testing.T) {
  1184  	mock := &mockAPI{
  1185  		listTemplatesFn: func(ctx context.Context, params *apis.ListTemplatesParams, editors ...apis.RequestEditorFn) (*apis.ListTemplatesResponse, error) {
  1186  			list := []apis.Template{
  1187  				{TemplateID: "tmpl-1", Names: []string{"qiniu/code-interpreter"}},
  1188  				{TemplateID: "tmpl-2"},
  1189  			}
  1190  			return &apis.ListTemplatesResponse{
  1191  				JSON200:      &list,
  1192  				HTTPResponse: httpResponse(200),
  1193  			}, nil
  1194  		},
  1195  	}
  1196  	c := newTestClient(mock)
  1197  	templates, err := c.ListTemplates(context.Background(), nil)
  1198  	if err != nil {
  1199  		t.Fatalf("unexpected error: %v", err)
  1200  	}
  1201  	if len(templates) != 2 {
  1202  		t.Errorf("expected 2 templates, got %d", len(templates))
  1203  	}
  1204  	if !slices.Equal(templates[0].Names, []string{"qiniu/code-interpreter"}) {
  1205  		t.Errorf("unexpected names: %v", templates[0].Names)
  1206  	}
  1207  }
  1208  
  1209  func TestDeleteTemplate(t *testing.T) {
  1210  	mock := &mockAPI{
  1211  		deleteTemplateFn: func(ctx context.Context, templateID apis.TemplateID, editors ...apis.RequestEditorFn) (*apis.DeleteTemplateResponse, error) {
  1212  			return &apis.DeleteTemplateResponse{HTTPResponse: httpResponse(204)}, nil
  1213  		},
  1214  	}
  1215  	c := newTestClient(mock)
  1216  	if err := c.DeleteTemplate(context.Background(), "tmpl-1"); err != nil {
  1217  		t.Fatalf("unexpected error: %v", err)
  1218  	}
  1219  }
  1220  
  1221  func TestGetTemplateByAlias(t *testing.T) {
  1222  	mock := &mockAPI{
  1223  		getTemplateByAliasFn: func(ctx context.Context, alias string, editors ...apis.RequestEditorFn) (*apis.GetTemplateByAliasResponse, error) {
  1224  			return &apis.GetTemplateByAliasResponse{
  1225  				JSON200:      &apis.TemplateAliasResponse{TemplateID: "tmpl-1", Public: true},
  1226  				HTTPResponse: httpResponse(200),
  1227  			}, nil
  1228  		},
  1229  	}
  1230  	c := newTestClient(mock)
  1231  	tmpl, err := c.GetTemplateByAlias(context.Background(), "my-alias")
  1232  	if err != nil {
  1233  		t.Fatalf("unexpected error: %v", err)
  1234  	}
  1235  	if tmpl.TemplateID != "tmpl-1" {
  1236  		t.Errorf("expected template ID 'tmpl-1', got %q", tmpl.TemplateID)
  1237  	}
  1238  }
  1239  
  1240  // --- WaitForBuild ---
  1241  
  1242  func TestWaitForBuildReady(t *testing.T) {
  1243  	mock := &mockAPI{
  1244  		getTemplateBuildStatusFn: func(ctx context.Context, templateID apis.TemplateID, buildID apis.BuildID, params *apis.GetTemplateBuildStatusParams, editors ...apis.RequestEditorFn) (*apis.GetTemplateBuildStatusResponse, error) {
  1245  			return &apis.GetTemplateBuildStatusResponse{
  1246  				JSON200:      &apis.TemplateBuildInfo{TemplateID: templateID, BuildID: buildID, Status: apis.TemplateBuildStatusReady},
  1247  				HTTPResponse: httpResponse(200),
  1248  			}, nil
  1249  		},
  1250  	}
  1251  	c := newTestClient(mock)
  1252  	info, err := c.WaitForBuild(context.Background(), "tmpl-1", "build-1", WithPollInterval(100*time.Millisecond))
  1253  	if err != nil {
  1254  		t.Fatalf("unexpected error: %v", err)
  1255  	}
  1256  	if info.Status != BuildStatusReady {
  1257  		t.Errorf("expected status 'ready', got %q", info.Status)
  1258  	}
  1259  }
  1260  
  1261  func TestWaitForBuildError(t *testing.T) {
  1262  	mock := &mockAPI{
  1263  		getTemplateBuildStatusFn: func(ctx context.Context, templateID apis.TemplateID, buildID apis.BuildID, params *apis.GetTemplateBuildStatusParams, editors ...apis.RequestEditorFn) (*apis.GetTemplateBuildStatusResponse, error) {
  1264  			return &apis.GetTemplateBuildStatusResponse{
  1265  				JSON200:      &apis.TemplateBuildInfo{TemplateID: templateID, BuildID: buildID, Status: apis.TemplateBuildStatusError},
  1266  				HTTPResponse: httpResponse(200),
  1267  			}, nil
  1268  		},
  1269  	}
  1270  	c := newTestClient(mock)
  1271  	info, err := c.WaitForBuild(context.Background(), "tmpl-1", "build-1", WithPollInterval(100*time.Millisecond))
  1272  	if err == nil {
  1273  		t.Fatal("expected error for failed build")
  1274  	}
  1275  	if info == nil {
  1276  		t.Fatal("expected non-nil build info even on error")
  1277  	}
  1278  	if info.Status != BuildStatusError {
  1279  		t.Errorf("expected status 'error', got %q", info.Status)
  1280  	}
  1281  }
  1282  
  1283  // --- APIError ---
  1284  
  1285  func TestAPIErrorMessage(t *testing.T) {
  1286  	// 直接构造(不使用工厂),Message 为空,回退到 body 格式
  1287  	err := &APIError{StatusCode: 404, Body: []byte(`{"message":"not found"}`)}
  1288  	msg := err.Error()
  1289  	if msg != `api error: status 404, body: {"message":"not found"}` {
  1290  		t.Errorf("unexpected error message: %s", msg)
  1291  	}
  1292  
  1293  	// 使用 newAPIError 工厂,自动解析 JSON body 中的 message
  1294  	err2 := newAPIError(httpResponse(404), []byte(`{"code":"not_found","message":"resource not found"}`))
  1295  	if err2.Code != "not_found" {
  1296  		t.Errorf("expected code 'not_found', got %q", err2.Code)
  1297  	}
  1298  	if err2.Message != "resource not found" {
  1299  		t.Errorf("expected message 'resource not found', got %q", err2.Message)
  1300  	}
  1301  	msg2 := err2.Error()
  1302  	if msg2 != "api error: status 404: resource not found" {
  1303  		t.Errorf("unexpected error message: %s", msg2)
  1304  	}
  1305  
  1306  	// 非 JSON body,回退到 body 格式
  1307  	err3 := newAPIError(httpResponse(500), []byte("internal error"))
  1308  	if err3.Code != "" || err3.Message != "" {
  1309  		t.Errorf("expected empty code/message for non-JSON body, got code=%q message=%q", err3.Code, err3.Message)
  1310  	}
  1311  	msg3 := err3.Error()
  1312  	if msg3 != "api error: status 500, body: internal error" {
  1313  		t.Errorf("unexpected error message: %s", msg3)
  1314  	}
  1315  
  1316  	// 空 body
  1317  	err4 := newAPIError(httpResponse(502), nil)
  1318  	if err4.Code != "" || err4.Message != "" {
  1319  		t.Errorf("expected empty code/message for nil body, got code=%q message=%q", err4.Code, err4.Message)
  1320  	}
  1321  	msg4 := err4.Error()
  1322  	if msg4 != "api error: status 502" {
  1323  		t.Errorf("unexpected error message: %s", msg4)
  1324  	}
  1325  
  1326  	// 带 X-Reqid 响应头
  1327  	respWithReqid := httpResponseWithReqid(400, "req-abc-123")
  1328  	err5 := newAPIError(respWithReqid, []byte(`{"code":"bad_request","message":"invalid param"}`))
  1329  	if err5.Reqid != "req-abc-123" {
  1330  		t.Errorf("expected reqid 'req-abc-123', got %q", err5.Reqid)
  1331  	}
  1332  	msg5 := err5.Error()
  1333  	if msg5 != "api error: status 400, reqid: req-abc-123: invalid param" {
  1334  		t.Errorf("unexpected error message: %s", msg5)
  1335  	}
  1336  }
  1337  
  1338  // --- Sandbox.GetMetrics ---
  1339  
  1340  func TestGetMetrics(t *testing.T) {
  1341  	mock := &mockAPI{
  1342  		getSandboxMetricsFn: func(ctx context.Context, sandboxID apis.SandboxID, params *apis.GetSandboxMetricsParams, editors ...apis.RequestEditorFn) (*apis.GetSandboxMetricsResponse, error) {
  1343  			metrics := []apis.SandboxMetric{{CPUUsedPct: 50.0, CPUCount: 2}}
  1344  			return &apis.GetSandboxMetricsResponse{
  1345  				JSON200:      &metrics,
  1346  				HTTPResponse: httpResponse(200),
  1347  			}, nil
  1348  		},
  1349  	}
  1350  	c := newTestClient(mock)
  1351  	sb := newTestSandbox(c, "sb-123")
  1352  	metrics, err := sb.GetMetrics(context.Background(), nil)
  1353  	if err != nil {
  1354  		t.Fatalf("unexpected error: %v", err)
  1355  	}
  1356  	if len(metrics) != 1 {
  1357  		t.Fatalf("expected 1 metric, got %d", len(metrics))
  1358  	}
  1359  	if metrics[0].CPUUsedPct != 50.0 {
  1360  		t.Errorf("expected CPU 50%%, got %f", metrics[0].CPUUsedPct)
  1361  	}
  1362  }
  1363  
  1364  // --- Sandbox.GetLogs ---
  1365  
  1366  func TestGetLogs(t *testing.T) {
  1367  	mock := &mockAPI{
  1368  		getSandboxLogsFn: func(ctx context.Context, sandboxID apis.SandboxID, params *apis.GetSandboxLogsParams, editors ...apis.RequestEditorFn) (*apis.GetSandboxLogsResponse, error) {
  1369  			return &apis.GetSandboxLogsResponse{
  1370  				JSON200:      &apis.SandboxLogs{Logs: []apis.SandboxLog{{Line: "hello"}}},
  1371  				HTTPResponse: httpResponse(200),
  1372  			}, nil
  1373  		},
  1374  	}
  1375  	c := newTestClient(mock)
  1376  	sb := newTestSandbox(c, "sb-123")
  1377  	logs, err := sb.GetLogs(context.Background(), nil)
  1378  	if err != nil {
  1379  		t.Fatalf("unexpected error: %v", err)
  1380  	}
  1381  	if len(logs.Logs) != 1 || logs.Logs[0].Line != "hello" {
  1382  		t.Errorf("unexpected logs: %v", logs)
  1383  	}
  1384  }
  1385  
  1386  // --- Sandbox.Refresh ---
  1387  
  1388  func TestRefresh(t *testing.T) {
  1389  	mock := &mockAPI{
  1390  		refreshSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.RefreshSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.RefreshSandboxResponse, error) {
  1391  			return &apis.RefreshSandboxResponse{HTTPResponse: httpResponse(204)}, nil
  1392  		},
  1393  	}
  1394  	c := newTestClient(mock)
  1395  	sb := newTestSandbox(c, "sb-123")
  1396  	if err := sb.Refresh(context.Background(), RefreshParams{}); err != nil {
  1397  		t.Fatalf("unexpected error: %v", err)
  1398  	}
  1399  }
  1400  
  1401  // --- Client.CreateAndWait ---
  1402  
  1403  func TestCreateAndWait(t *testing.T) {
  1404  	mock := &mockAPI{
  1405  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
  1406  			return &apis.CreateSandboxResponse{
  1407  				JSON201:      &apis.Sandbox{SandboxID: "sb-new", TemplateID: "tmpl-1"},
  1408  				HTTPResponse: httpResponse(201),
  1409  			}, nil
  1410  		},
  1411  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
  1412  			return &apis.GetSandboxResponse{
  1413  				JSON200:      &apis.SandboxDetail{SandboxID: sandboxID, State: apis.Running},
  1414  				HTTPResponse: httpResponse(200),
  1415  			}, nil
  1416  		},
  1417  	}
  1418  	c := newTestClient(mock)
  1419  	sb, info, err := c.CreateAndWait(context.Background(), CreateParams{TemplateID: "tmpl-1"}, WithPollInterval(100*time.Millisecond))
  1420  	if err != nil {
  1421  		t.Fatalf("unexpected error: %v", err)
  1422  	}
  1423  	if sb.ID() != "sb-new" {
  1424  		t.Errorf("expected sandbox ID 'sb-new', got %q", sb.ID())
  1425  	}
  1426  	if info.State != StateRunning {
  1427  		t.Errorf("expected state 'running', got %q", info.State)
  1428  	}
  1429  }
  1430  
  1431  func TestCreateAndWaitCreateFails(t *testing.T) {
  1432  	mock := &mockAPI{
  1433  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
  1434  			return &apis.CreateSandboxResponse{
  1435  				HTTPResponse: httpResponse(500),
  1436  				Body:         []byte("internal error"),
  1437  			}, nil
  1438  		},
  1439  	}
  1440  	c := newTestClient(mock)
  1441  	_, _, err := c.CreateAndWait(context.Background(), CreateParams{TemplateID: "tmpl-1"}, WithPollInterval(100*time.Millisecond))
  1442  	if err == nil {
  1443  		t.Fatal("expected error")
  1444  	}
  1445  }
  1446  
  1447  // --- Sandbox.WaitForReady 轮询 ---
  1448  
  1449  func TestWaitForReadyPolling(t *testing.T) {
  1450  	callCount := 0
  1451  	mock := &mockAPI{
  1452  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
  1453  			callCount++
  1454  			state := apis.Paused
  1455  			if callCount >= 3 {
  1456  				state = apis.Running
  1457  			}
  1458  			return &apis.GetSandboxResponse{
  1459  				JSON200:      &apis.SandboxDetail{SandboxID: sandboxID, State: state},
  1460  				HTTPResponse: httpResponse(200),
  1461  			}, nil
  1462  		},
  1463  	}
  1464  	c := newTestClient(mock)
  1465  	sb := newTestSandbox(c, "sb-123")
  1466  	info, err := sb.WaitForReady(context.Background(), WithPollInterval(50*time.Millisecond))
  1467  	if err != nil {
  1468  		t.Fatalf("unexpected error: %v", err)
  1469  	}
  1470  	if callCount < 3 {
  1471  		t.Errorf("expected at least 3 calls, got %d", callCount)
  1472  	}
  1473  	if info.State != StateRunning {
  1474  		t.Errorf("expected state 'running', got %q", info.State)
  1475  	}
  1476  }
  1477  
  1478  // --- 实例方法的错误用例 ---
  1479  
  1480  func TestKillError(t *testing.T) {
  1481  	mock := &mockAPI{
  1482  		deleteSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.DeleteSandboxResponse, error) {
  1483  			return &apis.DeleteSandboxResponse{
  1484  				HTTPResponse: httpResponse(404),
  1485  				Body:         []byte(`{"message":"not found"}`),
  1486  			}, nil
  1487  		},
  1488  	}
  1489  	c := newTestClient(mock)
  1490  	sb := newTestSandbox(c, "sb-999")
  1491  	err := sb.Kill(context.Background())
  1492  	if err == nil {
  1493  		t.Fatal("expected error")
  1494  	}
  1495  	apiErr, ok := err.(*APIError)
  1496  	if !ok {
  1497  		t.Fatalf("expected *APIError, got %T", err)
  1498  	}
  1499  	if apiErr.StatusCode != 404 {
  1500  		t.Errorf("expected status 404, got %d", apiErr.StatusCode)
  1501  	}
  1502  }
  1503  
  1504  func TestSetTimeoutError(t *testing.T) {
  1505  	mock := &mockAPI{
  1506  		updateSandboxTimeoutFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.UpdateSandboxTimeoutJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.UpdateSandboxTimeoutResponse, error) {
  1507  			return &apis.UpdateSandboxTimeoutResponse{
  1508  				HTTPResponse: httpResponse(404),
  1509  				Body:         []byte(`{"message":"not found"}`),
  1510  			}, nil
  1511  		},
  1512  	}
  1513  	c := newTestClient(mock)
  1514  	sb := newTestSandbox(c, "sb-999")
  1515  	err := sb.SetTimeout(context.Background(), time.Minute)
  1516  	if err == nil {
  1517  		t.Fatal("expected error")
  1518  	}
  1519  }
  1520  
  1521  func TestPauseError(t *testing.T) {
  1522  	mock := &mockAPI{
  1523  		pauseSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.PauseSandboxResponse, error) {
  1524  			return &apis.PauseSandboxResponse{
  1525  				HTTPResponse: httpResponse(409),
  1526  				Body:         []byte(`{"message":"conflict"}`),
  1527  			}, nil
  1528  		},
  1529  	}
  1530  	c := newTestClient(mock)
  1531  	sb := newTestSandbox(c, "sb-123")
  1532  	err := sb.Pause(context.Background())
  1533  	if err == nil {
  1534  		t.Fatal("expected error")
  1535  	}
  1536  }
  1537  
  1538  func TestConnectError(t *testing.T) {
  1539  	mock := &mockAPI{
  1540  		connectSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.ConnectSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.ConnectSandboxResponse, error) {
  1541  			return &apis.ConnectSandboxResponse{
  1542  				HTTPResponse: httpResponse(404),
  1543  				Body:         []byte(`{"message":"not found"}`),
  1544  			}, nil
  1545  		},
  1546  	}
  1547  	c := newTestClient(mock)
  1548  	_, err := c.Connect(context.Background(), "sb-999", ConnectParams{Timeout: 60})
  1549  	if err == nil {
  1550  		t.Fatal("expected error")
  1551  	}
  1552  }
  1553  
  1554  func TestGetInfoError(t *testing.T) {
  1555  	mock := &mockAPI{
  1556  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
  1557  			return &apis.GetSandboxResponse{
  1558  				HTTPResponse: httpResponse(404),
  1559  				Body:         []byte(`{"message":"not found"}`),
  1560  			}, nil
  1561  		},
  1562  	}
  1563  	c := newTestClient(mock)
  1564  	sb := newTestSandbox(c, "sb-999")
  1565  	_, err := sb.GetInfo(context.Background())
  1566  	if err == nil {
  1567  		t.Fatal("expected error")
  1568  	}
  1569  }
  1570  
  1571  // --- Template: CreateTemplate ---
  1572  
  1573  func TestCreateTemplate(t *testing.T) {
  1574  	mock := &mockAPI{
  1575  		createTemplateV3Fn: func(ctx context.Context, body apis.CreateTemplateV3JSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateTemplateV3Response, error) {
  1576  			return &apis.CreateTemplateV3Response{
  1577  				JSON202:      &apis.TemplateRequestResponseV3{TemplateID: "tmpl-new", BuildID: "build-1"},
  1578  				HTTPResponse: httpResponse(202),
  1579  			}, nil
  1580  		},
  1581  	}
  1582  	c := newTestClient(mock)
  1583  	resp, err := c.CreateTemplate(context.Background(), CreateTemplateParams{})
  1584  	if err != nil {
  1585  		t.Fatalf("unexpected error: %v", err)
  1586  	}
  1587  	if resp.TemplateID != "tmpl-new" {
  1588  		t.Errorf("expected template ID 'tmpl-new', got %q", resp.TemplateID)
  1589  	}
  1590  	if resp.BuildID != "build-1" {
  1591  		t.Errorf("expected build ID 'build-1', got %q", resp.BuildID)
  1592  	}
  1593  }
  1594  
  1595  func TestCreateTemplateError(t *testing.T) {
  1596  	mock := &mockAPI{
  1597  		createTemplateV3Fn: func(ctx context.Context, body apis.CreateTemplateV3JSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateTemplateV3Response, error) {
  1598  			return &apis.CreateTemplateV3Response{
  1599  				HTTPResponse: httpResponse(400),
  1600  				Body:         []byte(`{"message":"bad request"}`),
  1601  			}, nil
  1602  		},
  1603  	}
  1604  	c := newTestClient(mock)
  1605  	_, err := c.CreateTemplate(context.Background(), CreateTemplateParams{})
  1606  	if err == nil {
  1607  		t.Fatal("expected error")
  1608  	}
  1609  }
  1610  
  1611  // --- Template: RebuildTemplate ---
  1612  
  1613  func TestRebuildTemplate(t *testing.T) {
  1614  	var gotTemplateID apis.TemplateID
  1615  	var gotBody apis.RebuildTemplateJSONRequestBody
  1616  	mock := &mockAPI{
  1617  		rebuildTemplateFn: func(ctx context.Context, templateID apis.TemplateID, body apis.RebuildTemplateJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.RebuildTemplateResponse, error) {
  1618  			gotTemplateID = templateID
  1619  			gotBody = body
  1620  			return &apis.RebuildTemplateResponse{
  1621  				JSON202: &apis.TemplateLegacy{
  1622  					TemplateID: string(templateID),
  1623  					BuildID:    "build-2",
  1624  					Aliases:    []string{"my-template"},
  1625  				},
  1626  				HTTPResponse: httpResponse(202),
  1627  			}, nil
  1628  		},
  1629  	}
  1630  	c := newTestClient(mock)
  1631  	startCmd := "start.sh"
  1632  	resp, err := c.RebuildTemplate(context.Background(), "tmpl-existing", RebuildTemplateParams{
  1633  		Dockerfile: "FROM alpine\n",
  1634  		StartCmd:   &startCmd,
  1635  	})
  1636  	if err != nil {
  1637  		t.Fatalf("unexpected error: %v", err)
  1638  	}
  1639  	if gotTemplateID != "tmpl-existing" {
  1640  		t.Errorf("expected templateID 'tmpl-existing', got %q", gotTemplateID)
  1641  	}
  1642  	if gotBody.Dockerfile != "FROM alpine\n" {
  1643  		t.Errorf("expected dockerfile content forwarded, got %q", gotBody.Dockerfile)
  1644  	}
  1645  	if gotBody.StartCmd == nil || *gotBody.StartCmd != "start.sh" {
  1646  		t.Errorf("expected startCmd forwarded, got %v", gotBody.StartCmd)
  1647  	}
  1648  	if resp.TemplateID != "tmpl-existing" {
  1649  		t.Errorf("expected templateID 'tmpl-existing', got %q", resp.TemplateID)
  1650  	}
  1651  	if resp.BuildID != "build-2" {
  1652  		t.Errorf("expected BuildID 'build-2', got %q", resp.BuildID)
  1653  	}
  1654  }
  1655  
  1656  func TestRebuildTemplateError(t *testing.T) {
  1657  	mock := &mockAPI{
  1658  		rebuildTemplateFn: func(ctx context.Context, templateID apis.TemplateID, body apis.RebuildTemplateJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.RebuildTemplateResponse, error) {
  1659  			return &apis.RebuildTemplateResponse{
  1660  				HTTPResponse: httpResponse(400),
  1661  				Body:         []byte(`{"message":"bad request"}`),
  1662  			}, nil
  1663  		},
  1664  	}
  1665  	c := newTestClient(mock)
  1666  	_, err := c.RebuildTemplate(context.Background(), "tmpl-x", RebuildTemplateParams{Dockerfile: "FROM alpine\n"})
  1667  	if err == nil {
  1668  		t.Fatal("expected error")
  1669  	}
  1670  }
  1671  
  1672  func TestRebuildTemplateTransportError(t *testing.T) {
  1673  	mock := &mockAPI{
  1674  		rebuildTemplateFn: func(ctx context.Context, templateID apis.TemplateID, body apis.RebuildTemplateJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.RebuildTemplateResponse, error) {
  1675  			return nil, fmt.Errorf("network down")
  1676  		},
  1677  	}
  1678  	c := newTestClient(mock)
  1679  	_, err := c.RebuildTemplate(context.Background(), "tmpl-x", RebuildTemplateParams{Dockerfile: "FROM alpine\n"})
  1680  	if err == nil || err.Error() != "network down" {
  1681  		t.Fatalf("expected transport error 'network down', got %v", err)
  1682  	}
  1683  }
  1684  
  1685  func TestRebuildTemplateParamsToAPIForwardsAllFields(t *testing.T) {
  1686  	alias := "my-alias"
  1687  	cpu := int32(4)
  1688  	mem := int32(2048)
  1689  	start := "./start.sh"
  1690  	ready := "./ready.sh"
  1691  	team := "team-1"
  1692  	p := RebuildTemplateParams{
  1693  		Dockerfile: "FROM alpine\n",
  1694  		Alias:      &alias,
  1695  		CPUCount:   &cpu,
  1696  		MemoryMB:   &mem,
  1697  		StartCmd:   &start,
  1698  		ReadyCmd:   &ready,
  1699  		TeamID:     &team,
  1700  	}
  1701  	body := p.toAPI()
  1702  	if body.Dockerfile != "FROM alpine\n" {
  1703  		t.Errorf("Dockerfile not forwarded: %q", body.Dockerfile)
  1704  	}
  1705  	if body.Alias == nil || *body.Alias != alias {
  1706  		t.Errorf("Alias not forwarded: %v", body.Alias)
  1707  	}
  1708  	if body.CPUCount == nil || *body.CPUCount != cpu {
  1709  		t.Errorf("CPUCount not forwarded: %v", body.CPUCount)
  1710  	}
  1711  	if body.MemoryMB == nil || *body.MemoryMB != mem {
  1712  		t.Errorf("MemoryMB not forwarded: %v", body.MemoryMB)
  1713  	}
  1714  	if body.StartCmd == nil || *body.StartCmd != start {
  1715  		t.Errorf("StartCmd not forwarded: %v", body.StartCmd)
  1716  	}
  1717  	if body.ReadyCmd == nil || *body.ReadyCmd != ready {
  1718  		t.Errorf("ReadyCmd not forwarded: %v", body.ReadyCmd)
  1719  	}
  1720  	if body.TeamID == nil || *body.TeamID != team {
  1721  		t.Errorf("TeamID not forwarded: %v", body.TeamID)
  1722  	}
  1723  }
  1724  
  1725  func TestTemplateCreateResponseFromLegacyAPINil(t *testing.T) {
  1726  	if got := templateCreateResponseFromLegacyAPI(nil); got != nil {
  1727  		t.Errorf("expected nil, got %+v", got)
  1728  	}
  1729  }
  1730  
  1731  func TestTemplateCreateResponseFromAPINil(t *testing.T) {
  1732  	if got := templateCreateResponseFromAPI(nil); got != nil {
  1733  		t.Errorf("expected nil, got %+v", got)
  1734  	}
  1735  }
  1736  
  1737  // --- Template: GetTemplate ---
  1738  
  1739  func TestGetTemplate(t *testing.T) {
  1740  	mock := &mockAPI{
  1741  		getTemplateFn: func(ctx context.Context, templateID apis.TemplateID, params *apis.GetTemplateParams, editors ...apis.RequestEditorFn) (*apis.GetTemplateResponse, error) {
  1742  			return &apis.GetTemplateResponse{
  1743  				JSON200: &apis.TemplateWithBuilds{
  1744  					TemplateID: templateID,
  1745  					Names:      []string{"qiniu/code-interpreter"},
  1746  					IsOwner:    true,
  1747  					Builds:     []apis.TemplateBuild{{}},
  1748  				},
  1749  				HTTPResponse: httpResponse(200),
  1750  			}, nil
  1751  		},
  1752  	}
  1753  	c := newTestClient(mock)
  1754  	tmpl, err := c.GetTemplate(context.Background(), "tmpl-1", nil)
  1755  	if err != nil {
  1756  		t.Fatalf("unexpected error: %v", err)
  1757  	}
  1758  	if tmpl.TemplateID != "tmpl-1" {
  1759  		t.Errorf("expected template ID 'tmpl-1', got %q", tmpl.TemplateID)
  1760  	}
  1761  	if len(tmpl.Builds) != 1 {
  1762  		t.Errorf("expected 1 build, got %d", len(tmpl.Builds))
  1763  	}
  1764  	if !slices.Equal(tmpl.Names, []string{"qiniu/code-interpreter"}) {
  1765  		t.Errorf("unexpected names: %v", tmpl.Names)
  1766  	}
  1767  	if !tmpl.IsOwner {
  1768  		t.Error("expected template to be owned by caller")
  1769  	}
  1770  }
  1771  
  1772  // --- Template: GetTemplateBuildStatus ---
  1773  
  1774  func TestGetTemplateBuildStatus(t *testing.T) {
  1775  	mock := &mockAPI{
  1776  		getTemplateBuildStatusFn: func(ctx context.Context, templateID apis.TemplateID, buildID apis.BuildID, params *apis.GetTemplateBuildStatusParams, editors ...apis.RequestEditorFn) (*apis.GetTemplateBuildStatusResponse, error) {
  1777  			return &apis.GetTemplateBuildStatusResponse{
  1778  				JSON200:      &apis.TemplateBuildInfo{TemplateID: templateID, BuildID: buildID, Status: apis.TemplateBuildStatusBuilding},
  1779  				HTTPResponse: httpResponse(200),
  1780  			}, nil
  1781  		},
  1782  	}
  1783  	c := newTestClient(mock)
  1784  	info, err := c.GetTemplateBuildStatus(context.Background(), "tmpl-1", "build-1", nil)
  1785  	if err != nil {
  1786  		t.Fatalf("unexpected error: %v", err)
  1787  	}
  1788  	if info.Status != BuildStatusBuilding {
  1789  		t.Errorf("expected status 'building', got %q", info.Status)
  1790  	}
  1791  }
  1792  
  1793  // --- Template: WaitForBuild 轮询 ---
  1794  
  1795  func TestWaitForBuildPolling(t *testing.T) {
  1796  	callCount := 0
  1797  	mock := &mockAPI{
  1798  		getTemplateBuildStatusFn: func(ctx context.Context, templateID apis.TemplateID, buildID apis.BuildID, params *apis.GetTemplateBuildStatusParams, editors ...apis.RequestEditorFn) (*apis.GetTemplateBuildStatusResponse, error) {
  1799  			callCount++
  1800  			status := apis.TemplateBuildStatusBuilding
  1801  			if callCount >= 3 {
  1802  				status = apis.TemplateBuildStatusReady
  1803  			}
  1804  			return &apis.GetTemplateBuildStatusResponse{
  1805  				JSON200:      &apis.TemplateBuildInfo{TemplateID: templateID, BuildID: buildID, Status: status},
  1806  				HTTPResponse: httpResponse(200),
  1807  			}, nil
  1808  		},
  1809  	}
  1810  	c := newTestClient(mock)
  1811  	info, err := c.WaitForBuild(context.Background(), "tmpl-1", "build-1", WithPollInterval(50*time.Millisecond))
  1812  	if err != nil {
  1813  		t.Fatalf("unexpected error: %v", err)
  1814  	}
  1815  	if callCount < 3 {
  1816  		t.Errorf("expected at least 3 calls, got %d", callCount)
  1817  	}
  1818  	if info.Status != BuildStatusReady {
  1819  		t.Errorf("expected status 'ready', got %q", info.Status)
  1820  	}
  1821  }
  1822  
  1823  func TestWaitForBuildTimeout(t *testing.T) {
  1824  	mock := &mockAPI{
  1825  		getTemplateBuildStatusFn: func(ctx context.Context, templateID apis.TemplateID, buildID apis.BuildID, params *apis.GetTemplateBuildStatusParams, editors ...apis.RequestEditorFn) (*apis.GetTemplateBuildStatusResponse, error) {
  1826  			return &apis.GetTemplateBuildStatusResponse{
  1827  				JSON200:      &apis.TemplateBuildInfo{TemplateID: templateID, BuildID: buildID, Status: apis.TemplateBuildStatusBuilding},
  1828  				HTTPResponse: httpResponse(200),
  1829  			}, nil
  1830  		},
  1831  	}
  1832  	c := newTestClient(mock)
  1833  	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
  1834  	defer cancel()
  1835  	_, err := c.WaitForBuild(ctx, "tmpl-1", "build-1", WithPollInterval(50*time.Millisecond))
  1836  	if err == nil {
  1837  		t.Fatal("expected timeout error")
  1838  	}
  1839  }
  1840  
  1841  // --- Template: DeleteTemplate 错误 ---
  1842  
  1843  func TestDeleteTemplateError(t *testing.T) {
  1844  	mock := &mockAPI{
  1845  		deleteTemplateFn: func(ctx context.Context, templateID apis.TemplateID, editors ...apis.RequestEditorFn) (*apis.DeleteTemplateResponse, error) {
  1846  			return &apis.DeleteTemplateResponse{
  1847  				HTTPResponse: httpResponse(500),
  1848  				Body:         []byte("internal error"),
  1849  			}, nil
  1850  		},
  1851  	}
  1852  	c := newTestClient(mock)
  1853  	err := c.DeleteTemplate(context.Background(), "tmpl-1")
  1854  	if err == nil {
  1855  		t.Fatal("expected error")
  1856  	}
  1857  }
  1858  
  1859  // --- Template: GetTemplateByAlias 未找到 ---
  1860  
  1861  func TestGetTemplateByAliasNotFound(t *testing.T) {
  1862  	mock := &mockAPI{
  1863  		getTemplateByAliasFn: func(ctx context.Context, alias string, editors ...apis.RequestEditorFn) (*apis.GetTemplateByAliasResponse, error) {
  1864  			return &apis.GetTemplateByAliasResponse{
  1865  				HTTPResponse: httpResponse(404),
  1866  				Body:         []byte(`{"message":"not found"}`),
  1867  			}, nil
  1868  		},
  1869  	}
  1870  	c := newTestClient(mock)
  1871  	_, err := c.GetTemplateByAlias(context.Background(), "nonexistent")
  1872  	if err == nil {
  1873  		t.Fatal("expected error")
  1874  	}
  1875  	apiErr, ok := err.(*APIError)
  1876  	if !ok {
  1877  		t.Fatalf("expected *APIError, got %T", err)
  1878  	}
  1879  	if apiErr.StatusCode != 404 {
  1880  		t.Errorf("expected status 404, got %d", apiErr.StatusCode)
  1881  	}
  1882  }
  1883  
  1884  // --- Template: ListTemplates 错误 ---
  1885  
  1886  func TestListTemplatesError(t *testing.T) {
  1887  	mock := &mockAPI{
  1888  		listTemplatesFn: func(ctx context.Context, params *apis.ListTemplatesParams, editors ...apis.RequestEditorFn) (*apis.ListTemplatesResponse, error) {
  1889  			return &apis.ListTemplatesResponse{
  1890  				HTTPResponse: httpResponse(401),
  1891  				Body:         []byte(`{"message":"unauthorized"}`),
  1892  			}, nil
  1893  		},
  1894  	}
  1895  	c := newTestClient(mock)
  1896  	_, err := c.ListTemplates(context.Background(), nil)
  1897  	if err == nil {
  1898  		t.Fatal("expected error")
  1899  	}
  1900  }
  1901  
  1902  // --- Sandbox.GetSandboxesMetrics ---
  1903  
  1904  func TestGetSandboxesMetrics(t *testing.T) {
  1905  	mockWithMetrics := &mockAPIWithSandboxesMetrics{
  1906  		mockAPI: &mockAPI{},
  1907  		getSandboxesMetricsFn: func(ctx context.Context, params *apis.GetSandboxesMetricsParams, editors ...apis.RequestEditorFn) (*apis.GetSandboxesMetricsResponse, error) {
  1908  			return &apis.GetSandboxesMetricsResponse{
  1909  				JSON200:      &apis.SandboxesWithMetrics{Sandboxes: map[string]apis.SandboxMetric{"sb-1": {CPUCount: 4}}},
  1910  				HTTPResponse: httpResponse(200),
  1911  			}, nil
  1912  		},
  1913  	}
  1914  	c := newTestClient(mockWithMetrics)
  1915  	metrics, err := c.GetSandboxesMetrics(context.Background(), &GetSandboxesMetricsParams{SandboxIds: []string{"sb-1"}})
  1916  	if err != nil {
  1917  		t.Fatalf("unexpected error: %v", err)
  1918  	}
  1919  	if metrics.Sandboxes["sb-1"].CPUCount != 4 {
  1920  		t.Errorf("unexpected cpu count: %v", metrics.Sandboxes["sb-1"].CPUCount)
  1921  	}
  1922  }
  1923  
  1924  // mockAPIWithSandboxesMetrics 包装 mockAPI 并覆盖 GetSandboxesMetricsWithResponse。
  1925  type mockAPIWithSandboxesMetrics struct {
  1926  	*mockAPI
  1927  	getSandboxesMetricsFn func(ctx context.Context, params *apis.GetSandboxesMetricsParams, editors ...apis.RequestEditorFn) (*apis.GetSandboxesMetricsResponse, error)
  1928  }
  1929  
  1930  func (m *mockAPIWithSandboxesMetrics) GetSandboxesMetricsWithResponse(ctx context.Context, params *apis.GetSandboxesMetricsParams, editors ...apis.RequestEditorFn) (*apis.GetSandboxesMetricsResponse, error) {
  1931  	return m.getSandboxesMetricsFn(ctx, params, editors...)
  1932  }
  1933  
  1934  // --- Connect 返回 JSON201(新建沙箱) ---
  1935  
  1936  func TestConnectCreated(t *testing.T) {
  1937  	mock := &mockAPI{
  1938  		connectSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, body apis.ConnectSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.ConnectSandboxResponse, error) {
  1939  			return &apis.ConnectSandboxResponse{
  1940  				JSON201:      &apis.Sandbox{SandboxID: sandboxID, TemplateID: "tmpl-1"},
  1941  				HTTPResponse: httpResponse(201),
  1942  			}, nil
  1943  		},
  1944  		getSandboxFn: func(ctx context.Context, sandboxID apis.SandboxID, editors ...apis.RequestEditorFn) (*apis.GetSandboxResponse, error) {
  1945  			return &apis.GetSandboxResponse{
  1946  				JSON200:      &apis.SandboxDetail{SandboxID: sandboxID},
  1947  				HTTPResponse: httpResponse(200),
  1948  			}, nil
  1949  		},
  1950  	}
  1951  	c := newTestClient(mock)
  1952  	sb, err := c.Connect(context.Background(), "sb-new", ConnectParams{Timeout: 60})
  1953  	if err != nil {
  1954  		t.Fatalf("unexpected error: %v", err)
  1955  	}
  1956  	if sb.ID() != "sb-new" {
  1957  		t.Errorf("expected sandbox ID 'sb-new', got %q", sb.ID())
  1958  	}
  1959  }
  1960  
  1961  func TestRetryCall_RetryableStatusCode(t *testing.T) {
  1962  	c := newTestClient(&mockAPI{})
  1963  	var callCount int
  1964  	err := c.retryCall(context.Background(), func() error {
  1965  		callCount++
  1966  		if callCount < 3 {
  1967  			return &APIError{StatusCode: http.StatusRequestTimeout} // 408
  1968  		}
  1969  		return nil
  1970  	})
  1971  	if err != nil {
  1972  		t.Fatalf("expected nil error after retries, got: %v", err)
  1973  	}
  1974  	if callCount != 3 {
  1975  		t.Errorf("expected 3 calls, got %d", callCount)
  1976  	}
  1977  }
  1978  
  1979  func TestRetryCall_RetryableNetworkError(t *testing.T) {
  1980  	c := newTestClient(&mockAPI{})
  1981  	var callCount int
  1982  	err := c.retryCall(context.Background(), func() error {
  1983  		callCount++
  1984  		if callCount < 2 {
  1985  			return fmt.Errorf("connection refused")
  1986  		}
  1987  		return nil
  1988  	})
  1989  	if err != nil {
  1990  		t.Fatalf("expected nil error after retries, got: %v", err)
  1991  	}
  1992  	if callCount != 2 {
  1993  		t.Errorf("expected 2 calls, got %d", callCount)
  1994  	}
  1995  }
  1996  
  1997  func TestRetryCall_NoRetryOn4xx(t *testing.T) {
  1998  	c := newTestClient(&mockAPI{})
  1999  	var callCount int
  2000  	err := c.retryCall(context.Background(), func() error {
  2001  		callCount++
  2002  		return &APIError{StatusCode: http.StatusBadRequest}
  2003  	})
  2004  	if err == nil {
  2005  		t.Fatal("expected error, got nil")
  2006  	}
  2007  	if callCount != 1 {
  2008  		t.Errorf("expected 1 call (no retry), got %d", callCount)
  2009  	}
  2010  }
  2011  
  2012  func TestRetryCall_NoRetryOn409(t *testing.T) {
  2013  	c := newTestClient(&mockAPI{})
  2014  	var callCount int
  2015  	err := c.retryCall(context.Background(), func() error {
  2016  		callCount++
  2017  		return &APIError{StatusCode: http.StatusConflict}
  2018  	})
  2019  	if err == nil {
  2020  		t.Fatal("expected error, got nil")
  2021  	}
  2022  	if callCount != 1 {
  2023  		t.Errorf("expected 1 call (no retry), got %d", callCount)
  2024  	}
  2025  }
  2026  
  2027  func TestRetryCall_MaxRetries(t *testing.T) {
  2028  	c := newTestClient(&mockAPI{})
  2029  	var callCount int
  2030  	err := c.retryCall(context.Background(), func() error {
  2031  		callCount++
  2032  		return &APIError{StatusCode: http.StatusInternalServerError}
  2033  	})
  2034  	if err == nil {
  2035  		t.Fatal("expected error after max retries, got nil")
  2036  	}
  2037  	if callCount != 6 { // 1 initial + 5 retries
  2038  		t.Errorf("expected 6 calls, got %d", callCount)
  2039  	}
  2040  }
  2041  
  2042  func TestRetryCall_StopsWhenContextCanceled(t *testing.T) {
  2043  	c := newTestClient(&mockAPI{})
  2044  	ctx, cancel := context.WithCancel(context.Background())
  2045  	var callCount int
  2046  
  2047  	err := c.retryCall(ctx, func() error {
  2048  		callCount++
  2049  		cancel()
  2050  		return &APIError{StatusCode: http.StatusInternalServerError}
  2051  	})
  2052  
  2053  	if !errors.Is(err, context.Canceled) {
  2054  		t.Fatalf("expected context.Canceled, got %v", err)
  2055  	}
  2056  	if callCount != 1 {
  2057  		t.Errorf("expected 1 call after cancellation, got %d", callCount)
  2058  	}
  2059  }
  2060  
  2061  func TestRetryCall_CancelsDuringBackoff(t *testing.T) {
  2062  	c := newTestClient(&mockAPI{})
  2063  	backoffStarted := make(chan struct{})
  2064  	c.config.RetryBackoff = func(int) time.Duration {
  2065  		close(backoffStarted)
  2066  		return time.Hour
  2067  	}
  2068  	ctx, cancel := context.WithCancel(context.Background())
  2069  	result := make(chan error, 1)
  2070  
  2071  	go func() {
  2072  		result <- c.retryCall(ctx, func() error {
  2073  			return &APIError{StatusCode: http.StatusInternalServerError}
  2074  		})
  2075  	}()
  2076  
  2077  	<-backoffStarted
  2078  	cancel()
  2079  
  2080  	select {
  2081  	case err := <-result:
  2082  		if !errors.Is(err, context.Canceled) {
  2083  			t.Fatalf("expected context.Canceled, got %v", err)
  2084  		}
  2085  	case <-time.After(time.Second):
  2086  		t.Fatal("retryCall did not stop while waiting for backoff")
  2087  	}
  2088  }
  2089  
  2090  func TestCreate_RetryHTTPRequestsUseSameIdempotencyKey(t *testing.T) {
  2091  	const idempotencyKey = "stable-retry-key"
  2092  	var requestKeys []string
  2093  
  2094  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  2095  		requestKeys = append(requestKeys, r.Header.Get("Idempotency-Key"))
  2096  		w.Header().Set("Content-Type", "application/json")
  2097  		if len(requestKeys) < 3 {
  2098  			w.WriteHeader(http.StatusRequestTimeout)
  2099  			_, _ = fmt.Fprint(w, `{"message":"request timeout"}`)
  2100  			return
  2101  		}
  2102  		w.WriteHeader(http.StatusCreated)
  2103  		_, _ = fmt.Fprint(w, `{"sandboxID":"sb-1","templateID":"base","envdAccessToken":"token"}`)
  2104  	}))
  2105  	defer server.Close()
  2106  
  2107  	retryMax := 2
  2108  	c, err := NewClient(&Config{
  2109  		APIKey:       "test-key",
  2110  		Endpoint:     server.URL,
  2111  		RetryMax:     &retryMax,
  2112  		RetryBackoff: func(int) time.Duration { return 0 },
  2113  	})
  2114  	if err != nil {
  2115  		t.Fatalf("NewClient failed: %v", err)
  2116  	}
  2117  
  2118  	sb, err := c.Create(context.Background(), CreateParams{
  2119  		TemplateID:     "base",
  2120  		IdempotencyKey: idempotencyKey,
  2121  	})
  2122  	if err != nil {
  2123  		t.Fatalf("Create should succeed after retries: %v", err)
  2124  	}
  2125  	if sb.ID() != "sb-1" {
  2126  		t.Errorf("expected sandbox ID sb-1, got %s", sb.ID())
  2127  	}
  2128  	if len(requestKeys) != 3 {
  2129  		t.Fatalf("expected 3 HTTP requests, got %d", len(requestKeys))
  2130  	}
  2131  	for i, key := range requestKeys {
  2132  		if key != idempotencyKey {
  2133  			t.Errorf("request %d used idempotency key %q, expected %q", i+1, key, idempotencyKey)
  2134  		}
  2135  	}
  2136  }
  2137  
  2138  func TestCreate_IdempotencyKeyPassed(t *testing.T) {
  2139  	var capturedKey *string
  2140  	token := "tok"
  2141  	mock := &mockAPI{
  2142  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
  2143  			capturedKey = params.IdempotencyKey
  2144  			return &apis.CreateSandboxResponse{
  2145  				JSON201:      &apis.Sandbox{SandboxID: "sb-1", TemplateID: "tpl", EnvdAccessToken: &token},
  2146  				HTTPResponse: httpResponse(201),
  2147  			}, nil
  2148  		},
  2149  	}
  2150  	c := newTestClient(mock)
  2151  	_, err := c.Create(context.Background(), CreateParams{
  2152  		TemplateID:     "tpl",
  2153  		IdempotencyKey: "my-key",
  2154  	})
  2155  	if err != nil {
  2156  		t.Fatalf("Create failed: %v", err)
  2157  	}
  2158  	if capturedKey == nil || *capturedKey != "my-key" {
  2159  		t.Errorf("expected idempotency key 'my-key', got %v", capturedKey)
  2160  	}
  2161  }
  2162  
  2163  func TestCreate_AutoIdempotencyKey(t *testing.T) {
  2164  	var capturedKey *string
  2165  	token := "tok"
  2166  	mock := &mockAPI{
  2167  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
  2168  			capturedKey = params.IdempotencyKey
  2169  			return &apis.CreateSandboxResponse{
  2170  				JSON201:      &apis.Sandbox{SandboxID: "sb-1", TemplateID: "tpl", EnvdAccessToken: &token},
  2171  				HTTPResponse: httpResponse(201),
  2172  			}, nil
  2173  		},
  2174  	}
  2175  	c := newTestClient(mock)
  2176  	_, err := c.Create(context.Background(), CreateParams{
  2177  		TemplateID: "tpl",
  2178  	})
  2179  	if err != nil {
  2180  		t.Fatalf("Create failed: %v", err)
  2181  	}
  2182  	if capturedKey == nil || *capturedKey == "" {
  2183  		t.Error("expected auto-generated idempotency key, got empty")
  2184  	}
  2185  }
  2186  
  2187  func TestCreate_RetryOn408(t *testing.T) {
  2188  	var callCount int32
  2189  	token := "tok"
  2190  	mock := &mockAPI{
  2191  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
  2192  			atomic.AddInt32(&callCount, 1)
  2193  			if atomic.LoadInt32(&callCount) < 3 {
  2194  				return &apis.CreateSandboxResponse{HTTPResponse: httpResponse(408)}, nil
  2195  			}
  2196  			return &apis.CreateSandboxResponse{
  2197  				JSON201:      &apis.Sandbox{SandboxID: "sb-1", TemplateID: "tpl", EnvdAccessToken: &token},
  2198  				HTTPResponse: httpResponse(201),
  2199  			}, nil
  2200  		},
  2201  	}
  2202  	c := newTestClient(mock)
  2203  	sb, err := c.Create(context.Background(), CreateParams{TemplateID: "tpl"})
  2204  	if err != nil {
  2205  		t.Fatalf("Create should succeed after retries: %v", err)
  2206  	}
  2207  	if sb.ID() != "sb-1" {
  2208  		t.Errorf("expected sb-1, got %s", sb.ID())
  2209  	}
  2210  	if atomic.LoadInt32(&callCount) != 3 {
  2211  		t.Errorf("expected 3 calls, got %d", callCount)
  2212  	}
  2213  }
  2214  
  2215  func TestCreate_RetryOnNetworkError(t *testing.T) {
  2216  	var callCount int32
  2217  	token := "tok"
  2218  	mock := &mockAPI{
  2219  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
  2220  			atomic.AddInt32(&callCount, 1)
  2221  			if atomic.LoadInt32(&callCount) < 2 {
  2222  				return nil, fmt.Errorf("connection reset by peer")
  2223  			}
  2224  			return &apis.CreateSandboxResponse{
  2225  				JSON201:      &apis.Sandbox{SandboxID: "sb-1", TemplateID: "tpl", EnvdAccessToken: &token},
  2226  				HTTPResponse: httpResponse(201),
  2227  			}, nil
  2228  		},
  2229  	}
  2230  	c := newTestClient(mock)
  2231  	sb, err := c.Create(context.Background(), CreateParams{TemplateID: "tpl"})
  2232  	if err != nil {
  2233  		t.Fatalf("Create should succeed after retries: %v", err)
  2234  	}
  2235  	if sb.ID() != "sb-1" {
  2236  		t.Errorf("expected sb-1, got %s", sb.ID())
  2237  	}
  2238  	if atomic.LoadInt32(&callCount) != 2 {
  2239  		t.Errorf("expected 2 calls, got %d", callCount)
  2240  	}
  2241  }
  2242  
  2243  func TestCreate_KodoResourceWithoutCredentials(t *testing.T) {
  2244  	// cred == nil branch in Create is dead code —
  2245  	// GetCredentialsOption returns (nil, error), never (nil, nil)
  2246  	mock := &mockAPI{
  2247  		createSandboxFn: func(ctx context.Context, params *apis.CreateSandboxParams, body apis.CreateSandboxJSONRequestBody, editors ...apis.RequestEditorFn) (*apis.CreateSandboxResponse, error) {
  2248  			t.Error("should not reach API call when credentials are missing")
  2249  			return nil, nil
  2250  		},
  2251  	}
  2252  	c := newTestClient(mock)
  2253  	// Explicitly nil Credentials so GetCredentialsOption returns error
  2254  	c.config.Credentials = nil
  2255  	_, err := c.Create(context.Background(), CreateParams{
  2256  		TemplateID: "tpl",
  2257  		Resources: &[]SandboxResourceSpec{{
  2258  			Kodo: &KodoResource{Bucket: "test", MountPath: "/mnt"},
  2259  		}},
  2260  	})
  2261  	if err == nil {
  2262  		t.Fatal("expected error for missing kodo credentials")
  2263  	}
  2264  	// Should NOT get the "kodo resource requires" message (that's dead code)
  2265  	// Should get the "credentials not provided" message from GetCredentialsOption
  2266  	if !strings.Contains(err.Error(), "credentials not provided") {
  2267  		t.Errorf("expected 'credentials not provided', got: %v", err)
  2268  	}
  2269  }
  2270  
  2271  func TestIsRetryableError_WrappedNetworkError(t *testing.T) {
  2272  	// isRetryableError uses string matching which fails for wrapped errors
  2273  	netErr := &net.OpError{Op: "dial", Err: fmt.Errorf("i/o timeout")}
  2274  	wrapped := fmt.Errorf("create sandbox: %w", netErr)
  2275  	if !isRetryableError(wrapped) {
  2276  		t.Error("isRetryableError should detect timeout in wrapped *net.OpError")
  2277  	}
  2278  }
  2279  
  2280  func TestIsRetryableError_WrappedConnectionRefused(t *testing.T) {
  2281  	netErr := &net.OpError{Op: "dial", Err: fmt.Errorf("connection refused")}
  2282  	wrapped := fmt.Errorf("create sandbox: %w", netErr)
  2283  	if !isRetryableError(wrapped) {
  2284  		t.Error("isRetryableError should detect connection refused in wrapped *net.OpError")
  2285  	}
  2286  }
  2287  
  2288  func TestIsRetryable_WrappedAPIError(t *testing.T) {
  2289  	// isRetryable uses errors.As, correctly detects wrapped APIError
  2290  	apiErr := &APIError{StatusCode: 502}
  2291  	wrapped := fmt.Errorf("create sandbox: %w", apiErr)
  2292  	if !isRetryable(wrapped) {
  2293  		t.Error("isRetryable should detect 502 in wrapped *APIError via errors.As")
  2294  	}
  2295  }
  2296  
  2297  func TestIsRetryable_WrappedAPIError_ExpectRetry(t *testing.T) {
  2298  	apiErr := &APIError{StatusCode: 502}
  2299  	wrapped := fmt.Errorf("create sandbox: %w", apiErr)
  2300  	// This is the DESIRED behavior — currently FAILS
  2301  	_ = isRetryable(wrapped)
  2302  	// After fix, this should return true
  2303  	if !isRetryable(apiErr) {
  2304  		t.Error("baseline: isRetryable should retry on unwrapped 502")
  2305  	}
  2306  }
  2307  
  2308  func TestIsRetryable_ContextErrors(t *testing.T) {
  2309  	for _, err := range []error{context.Canceled, context.DeadlineExceeded} {
  2310  		if isRetryable(err) {
  2311  			t.Errorf("%v must not be retryable", err)
  2312  		}
  2313  	}
  2314  }
  2315  
  2316  func TestExponentialBackoff_SaturatesBeforeOverflow(t *testing.T) {
  2317  	delay := exponentialBackoff(35)
  2318  	if delay < 10*time.Second || delay > 15*time.Second {
  2319  		t.Fatalf("expected saturated delay between 10s and 15s, got %s", delay)
  2320  	}
  2321  }
  2322  
  2323  func TestNewClient_UsesRetryMaxFromEnvironment(t *testing.T) {
  2324  	t.Setenv("SANDBOX_RETRY_MAX", "0")
  2325  	c, err := NewClient(&Config{APIKey: "test-key"})
  2326  	if err != nil {
  2327  		t.Fatalf("NewClient failed: %v", err)
  2328  	}
  2329  	if c.config.RetryMax == nil || *c.config.RetryMax != 0 {
  2330  		t.Fatalf("expected RetryMax from environment to be 0, got %v", c.config.RetryMax)
  2331  	}
  2332  }
  2333  
  2334  func TestNewClient_RejectsInvalidRetryMax(t *testing.T) {
  2335  	negative := -1
  2336  	if _, err := NewClient(&Config{APIKey: "test-key", RetryMax: &negative}); err == nil {
  2337  		t.Fatal("expected negative RetryMax to be rejected")
  2338  	}
  2339  
  2340  	t.Setenv("SANDBOX_RETRY_MAX", "invalid")
  2341  	if _, err := NewClient(&Config{APIKey: "test-key"}); err == nil {
  2342  		t.Fatal("expected invalid SANDBOX_RETRY_MAX to be rejected")
  2343  	}
  2344  }