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 }