diff --git a/sdk/go/admin.go b/sdk/go/admin.go new file mode 100644 index 00000000..6f8fc2e0 --- /dev/null +++ b/sdk/go/admin.go @@ -0,0 +1,61 @@ +package scaledtest + +import ( + "context" + "net/url" +) + +// AdminService exposes the /api/v1/admin endpoints (owner role required). +type AdminService struct { + client *Client +} + +// ListUsersParams filters the users listing. +type ListUsersParams struct { + Limit int + Offset int +} + +// ListUsers returns all users (admin-only). +func (s *AdminService) ListUsers(ctx context.Context, p *ListUsersParams) (*ListUsersResponse, error) { + var q url.Values + if p != nil { + q = addInt(q, "limit", p.Limit) + q = addInt(q, "offset", p.Offset) + } + var out ListUsersResponse + if err := s.client.doAPI(ctx, "GET", "/admin/users", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListAuditLogParams filters the audit log query. +type ListAuditLogParams struct { + Action string + ResourceType string + ActorID string + Since string // RFC3339 + Until string // RFC3339 + Limit int + Offset int +} + +// ListAuditLog returns audit log entries (admin-only). +func (s *AdminService) ListAuditLog(ctx context.Context, p *ListAuditLogParams) (*ListAuditLogResponse, error) { + var q url.Values + if p != nil { + q = addQuery(q, "action", p.Action) + q = addQuery(q, "resource_type", p.ResourceType) + q = addQuery(q, "actor_id", p.ActorID) + q = addQuery(q, "since", p.Since) + q = addQuery(q, "until", p.Until) + q = addInt(q, "limit", p.Limit) + q = addInt(q, "offset", p.Offset) + } + var out ListAuditLogResponse + if err := s.client.doAPI(ctx, "GET", "/admin/audit-log", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/sdk/go/analytics.go b/sdk/go/analytics.go new file mode 100644 index 00000000..1b10f021 --- /dev/null +++ b/sdk/go/analytics.go @@ -0,0 +1,97 @@ +package scaledtest + +import ( + "context" + "net/url" +) + +// AnalyticsService exposes the /api/v1/analytics endpoints. +type AnalyticsService struct { + client *Client +} + +// TrendsParams filters the trends query. +type TrendsParams struct { + Start string // RFC3339 + End string // RFC3339 + GroupBy string // "day", "week", "month" +} + +// GetTrends returns pass/fail trends over time. +func (s *AnalyticsService) GetTrends(ctx context.Context, p *TrendsParams) (*TrendsResponse, error) { + var q url.Values + if p != nil { + q = addQuery(q, "start", p.Start) + q = addQuery(q, "end", p.End) + q = addQuery(q, "group_by", p.GroupBy) + } + var out TrendsResponse + if err := s.client.doAPI(ctx, "GET", "/analytics/trends", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// FlakyTestsParams filters the flaky-tests query. +type FlakyTestsParams struct { + WindowDays int + MinRuns int + Limit int +} + +// GetFlakyTests returns tests detected as flaky. +func (s *AnalyticsService) GetFlakyTests(ctx context.Context, p *FlakyTestsParams) (*FlakyTestsResponse, error) { + var q url.Values + if p != nil { + q = addInt(q, "window_days", p.WindowDays) + q = addInt(q, "min_runs", p.MinRuns) + q = addInt(q, "limit", p.Limit) + } + var out FlakyTestsResponse + if err := s.client.doAPI(ctx, "GET", "/analytics/flaky-tests", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ErrorAnalysisParams filters the error-analysis query. +type ErrorAnalysisParams struct { + Start string // RFC3339 + End string // RFC3339 + Limit int +} + +// GetErrorAnalysis returns clusters of similar error messages. +func (s *AnalyticsService) GetErrorAnalysis(ctx context.Context, p *ErrorAnalysisParams) (*ErrorAnalysisResponse, error) { + var q url.Values + if p != nil { + q = addQuery(q, "start", p.Start) + q = addQuery(q, "end", p.End) + q = addInt(q, "limit", p.Limit) + } + var out ErrorAnalysisResponse + if err := s.client.doAPI(ctx, "GET", "/analytics/error-analysis", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DurationDistributionParams filters the duration-distribution query. +type DurationDistributionParams struct { + Start string // RFC3339 + End string // RFC3339 +} + +// GetDurationDistribution returns a histogram of test durations. +func (s *AnalyticsService) GetDurationDistribution(ctx context.Context, p *DurationDistributionParams) (*DurationDistributionResponse, error) { + var q url.Values + if p != nil { + q = addQuery(q, "start", p.Start) + q = addQuery(q, "end", p.End) + } + var out DurationDistributionResponse + if err := s.client.doAPI(ctx, "GET", "/analytics/duration-distribution", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/sdk/go/auth.go b/sdk/go/auth.go new file mode 100644 index 00000000..fca04e17 --- /dev/null +++ b/sdk/go/auth.go @@ -0,0 +1,106 @@ +package scaledtest + +import ( + "context" + "errors" +) + +// AuthService exposes the /auth and /api/v1/auth endpoints. +// +// The ScaledTest refresh-token flow uses an HttpOnly cookie set by the +// server. Callers that need refresh should configure a cookie jar on the +// underlying *http.Client (via WithHTTPClient) so the refresh_token cookie +// is persisted across requests. For non-browser callers that cannot use a +// cookie jar, use the Auth.RefreshWithToken helper which sends the refresh +// token explicitly via the Authorization header (the ScaledTest server +// accepts a bearer refresh token on /auth/refresh as a fallback). +type AuthService struct { + client *Client +} + +// Register creates a new user account. +func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) (*AuthResponse, error) { + if req == nil || req.Email == "" || req.Password == "" || req.DisplayName == "" { + return nil, errors.New("scaledtest: email, password, and display_name are required") + } + var out AuthResponse + if err := s.client.doRaw(ctx, "POST", "/auth/register", nil, req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Login authenticates an existing user. +func (s *AuthService) Login(ctx context.Context, req *LoginRequest) (*AuthResponse, error) { + if req == nil || req.Email == "" || req.Password == "" { + return nil, errors.New("scaledtest: email and password are required") + } + var out AuthResponse + if err := s.client.doRaw(ctx, "POST", "/auth/login", nil, req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Refresh exchanges a refresh token for a new access token. The ScaledTest +// server reads the refresh token from the refresh_token cookie; callers using +// a cookie jar can call Refresh and rely on the jar. Callers without a cookie +// jar should use RefreshWithToken. +func (s *AuthService) Refresh(ctx context.Context) (*RefreshTokenResponse, error) { + var out RefreshTokenResponse + if err := s.client.doRaw(ctx, "POST", "/auth/refresh", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// RefreshWithToken sends the given refresh token as a Bearer header on the +// /auth/refresh request. This is the non-browser fallback for callers that +// do not maintain a cookie jar. The refresh token is sent only for this +// single request; the client's configured access token is not touched, so +// concurrent requests on the same Client are unaffected. +func (s *AuthService) RefreshWithToken(ctx context.Context, refreshToken string) (*RefreshTokenResponse, error) { + if refreshToken == "" { + return nil, errors.New("scaledtest: refresh token is required") + } + var out RefreshTokenResponse + if err := s.client.doRawWithToken(ctx, "POST", "/auth/refresh", nil, nil, &out, refreshToken); err != nil { + return nil, err + } + return &out, nil +} + +// GetMe returns the authenticated user's profile. +func (s *AuthService) GetMe(ctx context.Context) (*UserProfile, error) { + var out UserProfile + if err := s.client.doAPI(ctx, "GET", "/auth/me", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateProfile updates the authenticated user's display name. +func (s *AuthService) UpdateProfile(ctx context.Context, displayName string) (*UserProfile, error) { + if displayName == "" { + return nil, errors.New("scaledtest: display_name is required") + } + body := UpdateProfileRequest{DisplayName: displayName} + var out UserProfile + if err := s.client.doAPI(ctx, "PATCH", "/auth/me", nil, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ChangePassword changes the authenticated user's password. +func (s *AuthService) ChangePassword(ctx context.Context, currentPassword, newPassword string) (*ChangePasswordResponse, error) { + if currentPassword == "" || newPassword == "" { + return nil, errors.New("scaledtest: current_password and new_password are required") + } + body := ChangePasswordRequest{CurrentPassword: currentPassword, NewPassword: newPassword} + var out ChangePasswordResponse + if err := s.client.doAPI(ctx, "POST", "/auth/change-password", nil, body, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/sdk/go/client.go b/sdk/go/client.go new file mode 100644 index 00000000..ac8300c7 --- /dev/null +++ b/sdk/go/client.go @@ -0,0 +1,320 @@ +package scaledtest + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// defaultTimeout is the default per-request timeout for API calls. +const defaultTimeout = 30 * time.Second + +// apiVersion is the API path prefix used for all authenticated endpoints. +const apiVersion = "/api/v1" + +// Client is the ScaledTest API client. The returned service objects and the +// Client itself are safe for concurrent read use by multiple goroutines once +// constructed. The only method that mutates shared state is SetToken; callers +// that rotate the token at runtime must coordinate SetToken with in-flight +// requests themselves (e.g. by swapping to a new Client rather than mutating +// an existing one). The zero value is not usable; use NewClient. +type Client struct { + baseURL string + token string + httpClient *http.Client + userAgent string +} + +// Option configures a Client. +type Option func(*Client) error + +// WithToken sets the bearer token (JWT or sct_ API token) used for +// authentication. This is required for all authenticated endpoints. +func WithToken(token string) Option { + return func(c *Client) error { + if token == "" { + return errors.New("scaledtest: token must not be empty") + } + c.token = token + return nil + } +} + +// WithHTTPClient sets the underlying *http.Client used for requests. If not +// set, a client with the configured timeout is used. +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) error { + if hc == nil { + return errors.New("scaledtest: http client must not be nil") + } + c.httpClient = hc + return nil + } +} + +// WithTimeout sets the per-request timeout for API calls. A value of zero +// disables the client-side timeout (the context.Context on each call still +// applies). Defaults to 30 seconds. +func WithTimeout(d time.Duration) Option { + return func(c *Client) error { + if d < 0 { + return errors.New("scaledtest: timeout must not be negative") + } + // Apply by constructing a transport-backed client; we materialize the + // client in NewClient if not overridden by WithHTTPClient. + c.httpClient = &http.Client{Timeout: d} + return nil + } +} + +// WithUserAgent sets the User-Agent header sent with each request. +func WithUserAgent(ua string) Option { + return func(c *Client) error { + c.userAgent = ua + return nil + } +} + +// NewClient constructs a new ScaledTest API client targeting the given base +// URL (e.g. "https://your-instance.example.com"). The base URL must use http +// or https and must not include a trailing path. At minimum, WithToken must +// be supplied for authenticated endpoints; anonymous endpoints (health, +// invitation preview/accept, register/login) can be called without a token. +func NewClient(baseURL string, opts ...Option) (*Client, error) { + if baseURL == "" { + return nil, errors.New("scaledtest: baseUrl is required") + } + parsed, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("scaledtest: invalid baseUrl: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("scaledtest: baseUrl must use http or https (got %q)", parsed.Scheme) + } + // Strip trailing slash(es) so callers can pass either form. + base := baseURL + for strings.HasSuffix(base, "/") { + base = strings.TrimSuffix(base, "/") + } + + c := &Client{ + baseURL: base, + httpClient: &http.Client{Timeout: defaultTimeout}, + userAgent: "scaledtest-go-sdk", + } + for _, opt := range opts { + if opt == nil { + continue + } + if err := opt(c); err != nil { + return nil, err + } + } + if c.httpClient == nil { + c.httpClient = &http.Client{Timeout: defaultTimeout} + } + return c, nil +} + +// BaseURL returns the configured base URL (without trailing slash). +func (c *Client) BaseURL() string { return c.baseURL } + +// SetToken replaces the bearer token. This is intended for refresh flows +// where the access token is rotated at runtime. It is NOT safe to call +// SetToken while requests that depend on the previous token are in flight on +// other goroutines; for that scenario construct a new Client instead. For +// one-shot refresh-token usage that must not disturb concurrent callers, use +// AuthService.RefreshWithToken, which sends the refresh token per-request +// without mutating the client. +func (c *Client) SetToken(token string) { c.token = token } + +// Reports returns the Reports service. +func (c *Client) Reports() *ReportsService { return &ReportsService{client: c} } + +// Executions returns the Executions service. +func (c *Client) Executions() *ExecutionsService { return &ExecutionsService{client: c} } + +// Analytics returns the Analytics service. +func (c *Client) Analytics() *AnalyticsService { return &AnalyticsService{client: c} } + +// QualityGates returns the Quality Gates service. +func (c *Client) QualityGates() *QualityGatesService { return &QualityGatesService{client: c} } + +// Teams returns the Teams service (includes tokens, webhooks, invitations). +func (c *Client) Teams() *TeamsService { return &TeamsService{client: c} } + +// Sharding returns the Sharding service. +func (c *Client) Sharding() *ShardingService { return &ShardingService{client: c} } + +// Auth returns the Auth service. +func (c *Client) Auth() *AuthService { return &AuthService{client: c} } + +// Admin returns the Admin service. +func (c *Client) Admin() *AdminService { return &AdminService{client: c} } + +// Health returns the Health service. +func (c *Client) Health() *HealthService { return &HealthService{client: c} } + +// Invitations returns the public (token-scoped) Invitations service. +func (c *Client) Invitations() *InvitationsService { return &InvitationsService{client: c} } + +// do performs an HTTP request and decodes the JSON response into out. A nil +// out is allowed for endpoints with no response body (none currently). The +// request is authenticated unless noAuth is true. authToken, when non-empty, +// overrides c.token for this single request — used by the refresh flow to +// send a refresh token without mutating shared client state. +func (c *Client) do(ctx context.Context, method, path string, query url.Values, body interface{}, out interface{}, noAuth bool, authToken string) error { + fullURL := c.baseURL + path + if len(query) > 0 { + fullURL += "?" + query.Encode() + } + + var reader io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("scaledtest: marshal request body: %w", err) + } + reader = bytes.NewReader(buf) + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, reader) + if err != nil { + return fmt.Errorf("scaledtest: build request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + if !noAuth { + tok := authToken + if tok == "" { + tok = c.token + } + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + } + if c.userAgent != "" { + req.Header.Set("User-Agent", c.userAgent) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("scaledtest: http request: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return c.decodeError(resp) + } + + if out == nil { + // Drain the body so the connection can be reused. + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + + // A 202 with an empty-ish body still decodes into the target struct; if + // the body is genuinely empty, Unmarshal returns nil for a pointer target. + raw, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("scaledtest: read response body: %w", err) + } + if len(raw) == 0 { + return nil + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("scaledtest: decode response: %w", err) + } + return nil +} + +// decodeError reads the response body and returns a *ScaledTestError. +func (c *Client) decodeError(resp *http.Response) error { + raw, err := io.ReadAll(resp.Body) + if err != nil || len(raw) == 0 { + return &ScaledTestError{Status: resp.StatusCode, Message: fmt.Sprintf("HTTP %d", resp.StatusCode)} + } + var env errorEnvelope + if err := json.Unmarshal(raw, &env); err != nil || env.Error == "" { + return &ScaledTestError{Status: resp.StatusCode, Message: fmt.Sprintf("HTTP %d", resp.StatusCode)} + } + return &ScaledTestError{Status: resp.StatusCode, Code: env.Code, Message: env.Error} +} + +// doAPI is a convenience wrapper for authenticated /api/v1 calls. +func (c *Client) doAPI(ctx context.Context, method, path string, query url.Values, body, out interface{}) error { + return c.do(ctx, method, apiVersion+path, query, body, out, false, "") +} + +// doRaw is a convenience wrapper for unauthenticated calls (e.g. /health, +// /auth/register). The path already includes any prefix. +func (c *Client) doRaw(ctx context.Context, method, path string, query url.Values, body, out interface{}) error { + return c.do(ctx, method, path, query, body, out, true, "") +} + +// doRawWithToken is like doRaw but sends an explicit bearer token. Used by +// the refresh flow to send the refresh token without mutating c.token, so +// concurrent requests on the same Client are unaffected. +func (c *Client) doRawWithToken(ctx context.Context, method, path string, query url.Values, body, out interface{}, token string) error { + return c.do(ctx, method, path, query, body, out, false, token) +} + +// addQuery adds non-empty string values to a url.Values set, returning a new +// set if nil was passed in. +func addQuery(q url.Values, key, value string) url.Values { + if value == "" { + return q + } + if q == nil { + q = url.Values{} + } + q.Set(key, value) + return q +} + +// addInt adds a positive int to a url.Values set. +func addInt(q url.Values, key string, value int) url.Values { + if value <= 0 { + return q + } + if q == nil { + q = url.Values{} + } + q.Set(key, fmt.Sprintf("%d", value)) + return q +} + +// addBool adds a boolean to a url.Values set when cond is true. +func addBool(q url.Values, key string, cond bool) url.Values { + if !cond { + return q + } + if q == nil { + q = url.Values{} + } + q.Set(key, "true") + return q +} + +// pathEscape URL-escapes a single path segment. +func pathEscape(s string) string { + return url.PathEscape(s) +} + +// errMissingID is a small helper for the common " id is required" +// precondition. It is a plain error (not a *ScaledTestError) because it is +// raised client-side before any HTTP call. +func errMissingID(kind string) error { + return errors.New("scaledtest: " + kind + " id is required") +} diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go new file mode 100644 index 00000000..3e54b3ff --- /dev/null +++ b/sdk/go/client_test.go @@ -0,0 +1,1151 @@ +package scaledtest + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// testServer builds an httptest.Server whose handler dispatches to a per-method +// + path matcher. Each handler receives the request and returns (statusCode, body). +// If no matcher matches, the server returns 404. The handler also asserts the +// Authorization header is present when requireAuth is true. +type route struct { + method string + path string + h func(t *testing.T, w http.ResponseWriter, r *http.Request) +} + +func newTestServer(t *testing.T, routes []route) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + for _, rt := range routes { + rt := rt + mux.HandleFunc(rt.method+" "+rt.path, func(w http.ResponseWriter, r *http.Request) { + rt.h(t, w, r) + }) + } + // Catch-all 404 so unmatched routes fail loudly with a JSON error. + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusNotFound, map[string]string{"error": "no route for " + r.Method + " " + r.URL.Path}) + }) + return httptest.NewServer(mux) +} + +func writeJSON(t *testing.T, w http.ResponseWriter, status int, body interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + t.Fatalf("encode response: %v", err) + } +} + +func decodeBody(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read request body: %v", err) + } + if len(raw) == 0 { + return nil + } + var out map[string]interface{} + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode request body: %v (raw=%s)", err, string(raw)) + } + return out +} + +func mustAuth(t *testing.T, r *http.Request, want string) { + t.Helper() + got := r.Header.Get("Authorization") + if got != "Bearer "+want { + t.Fatalf("Authorization header = %q, want %q", got, "Bearer "+want) + } +} + +func mustNoAuth(t *testing.T, r *http.Request) { + t.Helper() + if got := r.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization header unexpectedly set: %q", got) + } +} + +func mustHaveQuery(t *testing.T, r *http.Request, key, want string) { + t.Helper() + got := r.URL.Query().Get(key) + if got != want { + t.Fatalf("query %q = %q, want %q", key, got, want) + } +} + +func mustHaveContentType(t *testing.T, r *http.Request) { + t.Helper() + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", ct) + } +} + +func ptr[T any](v T) *T { return &v } + +// ── Client construction ─────────────────────────────────────────────────────── + +func TestNewClient_ValidatesBaseURL(t *testing.T) { + cases := []struct { + name string + base string + wantErr string + }{ + {"empty", "", "baseUrl is required"}, + {"non-http", "ftp://example.com", "must use http or https"}, + {"invalid", "://", "invalid baseUrl"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewClient(tc.base, WithToken("tok")) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want substring %q", err, tc.wantErr) + } + }) + } +} + +func TestNewClient_StripsTrailingSlash(t *testing.T) { + c, err := NewClient("https://example.com/", WithToken("tok")) + if err != nil { + t.Fatal(err) + } + if c.BaseURL() != "https://example.com" { + t.Fatalf("BaseURL = %q, want no trailing slash", c.BaseURL()) + } + c2, err := NewClient("https://example.com///", WithToken("tok")) + if err != nil { + t.Fatal(err) + } + if c2.BaseURL() != "https://example.com" { + t.Fatalf("BaseURL = %q, want all trailing slashes stripped", c2.BaseURL()) + } +} + +func TestWithToken_RejectsEmpty(t *testing.T) { + if _, err := NewClient("https://example.com", WithToken("")); err == nil { + t.Fatal("expected error for empty token") + } +} + +func TestWithHTTPClient_RejectsNil(t *testing.T) { + if _, err := NewClient("https://example.com", WithHTTPClient(nil), WithToken("tok")); err == nil { + t.Fatal("expected error for nil http client") + } +} + +func TestWithTimeout_RejectsNegative(t *testing.T) { + if _, err := NewClient("https://example.com", WithTimeout(-1*time.Second), WithToken("tok")); err == nil { + t.Fatal("expected error for negative timeout") + } +} + +// ── Error decoding ──────────────────────────────────────────────────────────── + +func TestScaledTestError_Format(t *testing.T) { + e := &ScaledTestError{Status: 404, Message: "not found"} + if !strings.Contains(e.Error(), "404") || !strings.Contains(e.Error(), "not found") { + t.Fatalf("unexpected error string: %q", e.Error()) + } + e2 := &ScaledTestError{Status: 400, Code: "bad_request", Message: "nope"} + if !strings.Contains(e2.Error(), `code "bad_request"`) { + t.Fatalf("unexpected error string: %q", e2.Error()) + } +} + +func TestIsScaledTestError(t *testing.T) { + var err error = &ScaledTestError{Status: 500, Message: "boom"} + if !IsScaledTestError(err) { + t.Fatal("expected IsScaledTestError=true") + } + if ste, ok := AsScaledTestError(err); !ok || ste.Status != 500 { + t.Fatalf("AsScaledTestError = %v, %v", ste, ok) + } + if IsScaledTestError(http.ErrServerClosed) { + t.Fatal("expected IsScaledTestError=false for unrelated error") + } +} + +func TestClient_DecodesErrorEnvelope(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/reports/abc", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusNotFound, map[string]string{"error": "report not found"}) + }}, + }) + defer srv.Close() + + c, err := NewClient(srv.URL, WithToken("tok")) + if err != nil { + t.Fatal(err) + } + _, err = c.Reports().Get(context.Background(), "abc") + ste, ok := AsScaledTestError(err) + if !ok { + t.Fatalf("expected *ScaledTestError, got %T: %v", err, err) + } + if ste.Status != 404 || ste.Message != "report not found" { + t.Fatalf("err = %+v", ste) + } +} + +func TestClient_DecodesErrorEnvelopeWithCode(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/reports/abc", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusBadRequest, map[string]string{"error": "bad", "code": "invalid_id"}) + }}, + }) + defer srv.Close() + + c, err := NewClient(srv.URL, WithToken("tok")) + if err != nil { + t.Fatal(err) + } + _, err = c.Reports().Get(context.Background(), "abc") + ste, ok := AsScaledTestError(err) + if !ok || ste.Code != "invalid_id" { + t.Fatalf("err = %+v, ok=%v", ste, ok) + } +} + +func TestClient_ErrorBodyUnparseable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("not json at all")) + })) + defer srv.Close() + + c, err := NewClient(srv.URL, WithToken("tok")) + if err != nil { + t.Fatal(err) + } + _, err = c.Reports().Get(context.Background(), "abc") + ste, ok := AsScaledTestError(err) + if !ok || ste.Status != 500 { + t.Fatalf("err = %+v, ok=%v", ste, ok) + } + if !strings.Contains(ste.Message, "HTTP 500") { + t.Fatalf("expected fallback message, got %q", ste.Message) + } +} + +// ── Context cancellation ────────────────────────────────────────────────────── + +func TestClient_RespectsContextCancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate a slow response so cancellation can fire. + time.Sleep(50 * time.Millisecond) + writeJSON(t, w, http.StatusOK, map[string]string{"status": "ok"}) + })) + defer srv.Close() + + c, err := NewClient(srv.URL, WithToken("tok")) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + _, err = c.Health().Check(ctx) + if err == nil { + t.Fatal("expected context deadline error, got nil") + } +} + +// ── Reports ─────────────────────────────────────────────────────────────────── + +func TestReports_Upload(t *testing.T) { + var capturedBody map[string]interface{} + srv := newTestServer(t, []route{ + {"POST", "/api/v1/reports", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + mustHaveContentType(t, r) + capturedBody = decodeBody(t, r) + mustHaveQuery(t, r, "execution_id", "exec-1") + mustHaveQuery(t, r, "triage_github_status", "true") + writeJSON(t, w, http.StatusCreated, UploadReportResponse{ + ID: "r1", + Message: "report accepted", + Tool: "jest", + Tests: 10, + Results: 10, + }) + }}, + }) + defer srv.Close() + + c, _ := NewClient(srv.URL, WithToken("tok")) + resp, err := c.Reports().Upload(context.Background(), + &CtrfReport{Results: CtrfResults{Tool: CtrfTool{Name: "jest"}, Summary: CtrfSummary{Tests: 10}, Tests: []CtrfTest{{Name: "t1", Status: "passed"}}}}, + &UploadReportParams{ExecutionID: "exec-1", TriageGitHubStatus: true}) + if err != nil { + t.Fatal(err) + } + if resp.ID != "r1" || resp.Tests != 10 { + t.Fatalf("resp = %+v", resp) + } + if capturedBody["results"] == nil { + t.Fatalf("expected results in body, got %v", capturedBody) + } +} + +func TestReports_List(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/reports", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + mustHaveQuery(t, r, "limit", "20") + mustHaveQuery(t, r, "offset", "5") + mustHaveQuery(t, r, "since", "2026-01-01T00:00:00Z") + writeJSON(t, w, http.StatusOK, ListReportsResponse{ + Reports: []Report{{ID: "r1", TeamID: "t1", Name: "jest"}}, + Total: 1, + }) + }}, + }) + defer srv.Close() + + c, _ := NewClient(srv.URL, WithToken("tok")) + out, err := c.Reports().List(context.Background(), &ListReportsParams{Limit: 20, Offset: 5, Since: "2026-01-01T00:00:00Z"}) + if err != nil { + t.Fatal(err) + } + if out.Total != 1 || len(out.Reports) != 1 || out.Reports[0].ID != "r1" { + t.Fatalf("out = %+v", out) + } +} + +func TestReports_Get(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/reports/r1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, Report{ID: "r1", Name: "jest"}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + r, err := c.Reports().Get(context.Background(), "r1") + if err != nil { + t.Fatal(err) + } + if r.ID != "r1" { + t.Fatalf("id = %q", r.ID) + } + + if _, err := c.Reports().Get(context.Background(), ""); err == nil { + t.Fatal("expected error for empty id") + } +} + +func TestReports_Delete(t *testing.T) { + srv := newTestServer(t, []route{ + {"DELETE", "/api/v1/reports/r1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, DeleteReportResponse{ID: "r1", Deleted: true}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + out, err := c.Reports().Delete(context.Background(), "r1") + if err != nil { + t.Fatal(err) + } + if !out.Deleted { + t.Fatalf("out = %+v", out) + } +} + +func TestReports_Compare(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/reports/compare", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + mustHaveQuery(t, r, "base", "b1") + mustHaveQuery(t, r, "head", "h1") + writeJSON(t, w, http.StatusOK, ReportCompareResult{ + Base: CompareReport{ID: "b1"}, + Head: CompareReport{ID: "h1"}, + Diff: ReportDiff{Summary: ReportDiffSummary{BaseTests: 5, HeadTests: 6, NewFailures: 1}}, + }) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + out, err := c.Reports().Compare(context.Background(), "b1", "h1") + if err != nil { + t.Fatal(err) + } + if out.Diff.Summary.NewFailures != 1 { + t.Fatalf("out = %+v", out) + } + if _, err := c.Reports().Compare(context.Background(), "", "h1"); err == nil { + t.Fatal("expected error for empty base") + } +} + +func TestReports_GetTriage(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/reports/r1/triage", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, ReportTriageResult{ + TriageStatus: "complete", + Clusters: []TriageCluster{{ID: "c1", RootCause: "timeout"}}, + }) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + out, err := c.Reports().GetTriage(context.Background(), "r1") + if err != nil { + t.Fatal(err) + } + if out.TriageStatus != "complete" || len(out.Clusters) != 1 { + t.Fatalf("out = %+v", out) + } +} + +func TestReports_RetryTriage(t *testing.T) { + srv := newTestServer(t, []route{ + {"POST", "/api/v1/reports/r1/triage/retry", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusAccepted, RetryTriageResponse{TriageStatus: "pending"}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + out, err := c.Reports().RetryTriage(context.Background(), "r1") + if err != nil { + t.Fatal(err) + } + if out.TriageStatus != "pending" { + t.Fatalf("out = %+v", out) + } +} + +// ── Executions ──────────────────────────────────────────────────────────────── + +func TestExecutions_Create(t *testing.T) { + srv := newTestServer(t, []route{ + {"POST", "/api/v1/executions", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["command"] != "npm test" { + t.Fatalf("command = %v", body["command"]) + } + if body["image"] != "node:20" { + t.Fatalf("image = %v", body["image"]) + } + writeJSON(t, w, http.StatusCreated, CreateExecutionResponse{ID: "e1", Status: "pending", Command: "npm test"}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + out, err := c.Executions().Create(context.Background(), "npm test", &CreateExecutionOptions{Image: "node:20", EnvVars: map[string]string{"FOO": "bar"}}) + if err != nil { + t.Fatal(err) + } + if out.ID != "e1" { + t.Fatalf("id = %q", out.ID) + } + if _, err := c.Executions().Create(context.Background(), "", nil); err == nil { + t.Fatal("expected error for empty command") + } +} + +func TestExecutions_List_Get_Cancel(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/executions", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + mustHaveQuery(t, r, "limit", "10") + writeJSON(t, w, http.StatusOK, ListExecutionsResponse{Executions: []Execution{{ID: "e1"}}, Total: 1}) + }}, + {"GET", "/api/v1/executions/e1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, Execution{ID: "e1", Status: ExecutionStatusRunning}) + }}, + {"DELETE", "/api/v1/executions/e1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, CancelExecutionResponse{ID: "e1", Status: "cancelled"}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + + lst, err := c.Executions().List(context.Background(), &ListExecutionsParams{Limit: 10}) + if err != nil || lst.Total != 1 { + t.Fatalf("list err=%v out=%+v", err, lst) + } + g, err := c.Executions().Get(context.Background(), "e1") + if err != nil || g.Status != ExecutionStatusRunning { + t.Fatalf("get err=%v out=%+v", err, g) + } + del, err := c.Executions().Cancel(context.Background(), "e1") + if err != nil || del.Status != "cancelled" { + t.Fatalf("cancel err=%v out=%+v", err, del) + } + // Delete is alias for Cancel + if _, err := c.Executions().Delete(context.Background(), "e1"); err == nil { + // second call would hit 404; just check no panic + } +} + +func TestExecutions_UpdateStatus(t *testing.T) { + srv := newTestServer(t, []route{ + {"PUT", "/api/v1/executions/e1/status", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["status"] != "failed" || body["error_msg"] != "boom" { + t.Fatalf("body = %v", body) + } + writeJSON(t, w, http.StatusOK, UpdateExecutionStatusResponse{ID: "e1", Status: "failed"}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + out, err := c.Executions().UpdateStatus(context.Background(), "e1", UpdateExecutionFailed, "boom") + if err != nil || out.Status != "failed" { + t.Fatalf("err=%v out=%+v", err, out) + } +} + +func TestExecutions_WorkerCallbacks(t *testing.T) { + var lastPath string + srv := newTestServer(t, []route{ + {"POST", "/api/v1/executions/e1/progress", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + lastPath = r.URL.Path + body := decodeBody(t, r) + if body["total"] != float64(10) { + t.Fatalf("total = %v", body["total"]) + } + writeJSON(t, w, http.StatusOK, ExecutionProgressResponse{ExecutionID: "e1", Received: true}) + }}, + {"POST", "/api/v1/executions/e1/test-result", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + lastPath = r.URL.Path + body := decodeBody(t, r) + if body["name"] != "test A" { + t.Fatalf("name = %v", body["name"]) + } + writeJSON(t, w, http.StatusOK, ExecutionReceivedResponse{ExecutionID: "e1", Received: true}) + }}, + {"POST", "/api/v1/executions/e1/worker-status", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + lastPath = r.URL.Path + body := decodeBody(t, r) + if body["worker_id"] != "w1" { + t.Fatalf("worker_id = %v", body["worker_id"]) + } + writeJSON(t, w, http.StatusOK, ExecutionReceivedResponse{ExecutionID: "e1", Received: true}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + + p, err := c.Executions().ReportProgress(context.Background(), "e1", &ExecutionProgress{Passed: 5, Failed: 1, Skipped: 1, Total: 10}) + if err != nil || !p.Received { + t.Fatalf("progress err=%v out=%+v", err, p) + } + if lastPath != "/api/v1/executions/e1/progress" { + t.Fatalf("path = %q", lastPath) + } + + tr, err := c.Executions().ReportTestResult(context.Background(), "e1", &TestResultEvent{Name: "test A", Status: TestResultFailed}) + if err != nil || !tr.Received { + t.Fatalf("test-result err=%v out=%+v", err, tr) + } + + ws, err := c.Executions().ReportWorkerStatus(context.Background(), "e1", &WorkerStatusEvent{WorkerID: "w1", Status: WorkerStatusRunning}) + if err != nil || !ws.Received { + t.Fatalf("worker-status err=%v out=%+v", err, ws) + } + + if _, err := c.Executions().ReportProgress(context.Background(), "e1", nil); err == nil { + t.Fatal("expected error for nil progress") + } +} + +// ── Analytics ───────────────────────────────────────────────────────────────── + +func TestAnalytics_AllEndpoints(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/analytics/trends", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + mustHaveQuery(t, r, "group_by", "week") + writeJSON(t, w, http.StatusOK, TrendsResponse{Trends: []TrendPoint{{Date: "2026-01-01", PassRate: 0.9, Total: 10}}}) + }}, + {"GET", "/api/v1/analytics/flaky-tests", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + mustHaveQuery(t, r, "window_days", "7") + writeJSON(t, w, http.StatusOK, FlakyTestsResponse{FlakyTests: []FlakyTest{{Name: "t1", FlipCount: 3}}}) + }}, + {"GET", "/api/v1/analytics/error-analysis", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, ErrorAnalysisResponse{Errors: []ErrorCluster{{Message: "timeout", Count: 2}}}) + }}, + {"GET", "/api/v1/analytics/duration-distribution", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, DurationDistributionResponse{Distribution: []DurationBucket{{Range: "0-100ms", Count: 5}}}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + + tr, err := c.Analytics().GetTrends(context.Background(), &TrendsParams{GroupBy: "week"}) + if err != nil || len(tr.Trends) != 1 { + t.Fatalf("trends err=%v out=%+v", err, tr) + } + ft, err := c.Analytics().GetFlakyTests(context.Background(), &FlakyTestsParams{WindowDays: 7}) + if err != nil || len(ft.FlakyTests) != 1 { + t.Fatalf("flaky err=%v out=%+v", err, ft) + } + ea, err := c.Analytics().GetErrorAnalysis(context.Background(), nil) + if err != nil || len(ea.Errors) != 1 { + t.Fatalf("error-analysis err=%v out=%+v", err, ea) + } + dd, err := c.Analytics().GetDurationDistribution(context.Background(), nil) + if err != nil || len(dd.Distribution) != 1 { + t.Fatalf("duration-dist err=%v out=%+v", err, dd) + } +} + +// ── Quality Gates ───────────────────────────────────────────────────────────── + +func TestQualityGates_CRUD(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/teams/t1/quality-gates", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, ListQualityGatesResponse{QualityGates: []QualityGate{{ID: "g1"}}, Total: 1}) + }}, + {"POST", "/api/v1/teams/t1/quality-gates", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["name"] != "gate1" { + t.Fatalf("name = %v", body["name"]) + } + writeJSON(t, w, http.StatusCreated, QualityGate{ID: "g1", Name: "gate1"}) + }}, + {"GET", "/api/v1/teams/t1/quality-gates/g1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, QualityGate{ID: "g1"}) + }}, + {"PUT", "/api/v1/teams/t1/quality-gates/g1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["enabled"] != false { + t.Fatalf("enabled = %v", body["enabled"]) + } + writeJSON(t, w, http.StatusOK, QualityGate{ID: "g1", Enabled: false}) + }}, + {"DELETE", "/api/v1/teams/t1/quality-gates/g1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, DeleteQualityGateResponse{Message: "deleted"}) + }}, + {"POST", "/api/v1/teams/t1/quality-gates/g1/evaluate", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["report_id"] != "r1" { + t.Fatalf("report_id = %v", body["report_id"]) + } + writeJSON(t, w, http.StatusOK, EvaluateQualityGateResponse{ID: "ev1", GateID: "g1", ReportID: "r1", Passed: true}) + }}, + {"GET", "/api/v1/teams/t1/quality-gates/g1/evaluations", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + mustHaveQuery(t, r, "limit", "5") + writeJSON(t, w, http.StatusOK, ListEvaluationsResponse{Evaluations: []QualityGateEvaluation{{ID: "ev1"}}, Total: 1}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + + lst, err := c.QualityGates().List(context.Background(), "t1") + if err != nil || lst.Total != 1 { + t.Fatalf("list err=%v out=%+v", err, lst) + } + enabled := false + created, err := c.QualityGates().Create(context.Background(), "t1", &CreateQualityGateParams{ + Name: "gate1", + Rules: []QualityGateRule{{Type: "pass_rate", Params: json.RawMessage(`{"min":0.9}`)}}, + }) + if err != nil || created.ID != "g1" { + t.Fatalf("create err=%v out=%+v", err, created) + } + g, err := c.QualityGates().Get(context.Background(), "t1", "g1") + if err != nil || g.ID != "g1" { + t.Fatalf("get err=%v out=%+v", err, g) + } + upd, err := c.QualityGates().Update(context.Background(), "t1", "g1", &UpdateQualityGateParams{ + Name: "gate1", + Rules: []QualityGateRule{{Type: "pass_rate", Params: json.RawMessage(`{"min":0.9}`)}}, + Enabled: &enabled, + }) + if err != nil || upd.Enabled { + t.Fatalf("update err=%v out=%+v", err, upd) + } + del, err := c.QualityGates().Delete(context.Background(), "t1", "g1") + if err != nil || del.Message != "deleted" { + t.Fatalf("delete err=%v out=%+v", err, del) + } + ev, err := c.QualityGates().Evaluate(context.Background(), "t1", "g1", "r1") + if err != nil || !ev.Passed { + t.Fatalf("evaluate err=%v out=%+v", err, ev) + } + evs, err := c.QualityGates().ListEvaluations(context.Background(), "t1", "g1", 5) + if err != nil || evs.Total != 1 { + t.Fatalf("evaluations err=%v out=%+v", err, evs) + } + + if _, err := c.QualityGates().Create(context.Background(), "t1", &CreateQualityGateParams{Name: "x"}); err == nil { + t.Fatal("expected error for empty rules") + } + if _, err := c.QualityGates().Create(context.Background(), "", nil); err == nil { + t.Fatal("expected error for empty team") + } +} + +// ── Teams ───────────────────────────────────────────────────────────────────── + +func TestTeams_Basic(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/teams", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, ListTeamsResponse{Teams: []TeamWithRole{{Team: Team{ID: "t1", Name: "team1"}, Role: "owner"}}}) + }}, + {"POST", "/api/v1/teams", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["name"] != "new team" { + t.Fatalf("name = %v", body["name"]) + } + writeJSON(t, w, http.StatusCreated, Team{ID: "t2", Name: "new team"}) + }}, + {"GET", "/api/v1/teams/t1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, GetTeamResponse{Team: Team{ID: "t1", Name: "team1"}, Role: "owner"}) + }}, + {"DELETE", "/api/v1/teams/t1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, DeleteTeamResponse{Message: "team deleted"}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + + lst, err := c.Teams().List(context.Background()) + if err != nil || len(lst.Teams) != 1 { + t.Fatalf("list err=%v out=%+v", err, lst) + } + created, err := c.Teams().Create(context.Background(), "new team") + if err != nil || created.ID != "t2" { + t.Fatalf("create err=%v out=%+v", err, created) + } + g, err := c.Teams().Get(context.Background(), "t1") + if err != nil || g.Role != "owner" { + t.Fatalf("get err=%v out=%+v", err, g) + } + del, err := c.Teams().Delete(context.Background(), "t1") + if err != nil || del.Message != "team deleted" { + t.Fatalf("delete err=%v out=%+v", err, del) + } + if _, err := c.Teams().Create(context.Background(), ""); err == nil { + t.Fatal("expected error for empty name") + } +} + +func TestTeams_Tokens(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/teams/t1/tokens", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, ListTokensResponse{Tokens: []TeamToken{{ID: "tk1", Name: "ci"}}}) + }}, + {"POST", "/api/v1/teams/t1/tokens", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["name"] != "ci" { + t.Fatalf("name = %v", body["name"]) + } + writeJSON(t, w, http.StatusCreated, CreateTokenResponse{Token: "sct_secret", ID: "tk1", Name: "ci"}) + }}, + {"DELETE", "/api/v1/teams/t1/tokens/tk1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, DeleteTokenResponse{Message: "token revoked"}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + lst, err := c.Teams().ListTokens(context.Background(), "t1") + if err != nil || len(lst.Tokens) != 1 { + t.Fatalf("list err=%v", err) + } + created, err := c.Teams().CreateToken(context.Background(), "t1", "ci") + if err != nil || created.Token != "sct_secret" { + t.Fatalf("create err=%v out=%+v", err, created) + } + del, err := c.Teams().DeleteToken(context.Background(), "t1", "tk1") + if err != nil || del.Message != "token revoked" { + t.Fatalf("delete err=%v", err) + } +} + +func TestTeams_Webhooks(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/teams/t1/webhooks", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, ListWebhooksResponse{Webhooks: []Webhook{{ID: "w1"}}, Total: 1}) + }}, + {"POST", "/api/v1/teams/t1/webhooks", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["url"] != "https://example.com/hook" { + t.Fatalf("url = %v", body["url"]) + } + writeJSON(t, w, http.StatusCreated, CreateWebhookResponse{Webhook: Webhook{ID: "w1"}, Secret: "whsec_x"}) + }}, + {"GET", "/api/v1/teams/t1/webhooks/w1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, Webhook{ID: "w1"}) + }}, + {"PUT", "/api/v1/teams/t1/webhooks/w1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, Webhook{ID: "w1"}) + }}, + {"DELETE", "/api/v1/teams/t1/webhooks/w1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, DeleteWebhookResponse{Message: "webhook deleted"}) + }}, + {"GET", "/api/v1/teams/t1/webhooks/w1/deliveries", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + mustHaveQuery(t, r, "limit", "10") + writeJSON(t, w, http.StatusOK, ListWebhookDeliveriesResponse{Deliveries: []WebhookDelivery{{ID: "d1"}}, Total: 1}) + }}, + {"POST", "/api/v1/teams/t1/webhooks/w1/deliveries/d1/retry", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, RetryWebhookDeliveryResponse{Success: true, StatusCode: 200}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + + lst, err := c.Teams().ListWebhooks(context.Background(), "t1") + if err != nil || lst.Total != 1 { + t.Fatalf("list err=%v", err) + } + created, err := c.Teams().CreateWebhook(context.Background(), "t1", "https://example.com/hook", []WebhookEventType{WebhookEventReportSubmitted}) + if err != nil || created.Secret != "whsec_x" { + t.Fatalf("create err=%v out=%+v", err, created) + } + if _, err := c.Teams().GetWebhook(context.Background(), "t1", "w1"); err != nil { + t.Fatal(err) + } + enabled := true + if _, err := c.Teams().UpdateWebhook(context.Background(), "t1", "w1", &UpdateWebhookParams{URL: "https://example.com/hook", Events: []WebhookEventType{WebhookEventGateFailed}, Enabled: &enabled}); err != nil { + t.Fatal(err) + } + if _, err := c.Teams().DeleteWebhook(context.Background(), "t1", "w1"); err != nil { + t.Fatal(err) + } + dels, err := c.Teams().ListWebhookDeliveries(context.Background(), "t1", "w1", &ListWebhookDeliveriesParams{Limit: 10}) + if err != nil || dels.Total != 1 { + t.Fatalf("deliveries err=%v", err) + } + retry, err := c.Teams().RetryWebhookDelivery(context.Background(), "t1", "w1", "d1") + if err != nil || !retry.Success { + t.Fatalf("retry err=%v", err) + } + if _, err := c.Teams().CreateWebhook(context.Background(), "t1", "", nil); err == nil { + t.Fatal("expected error for empty url") + } +} + +func TestTeams_Invitations(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/teams/t1/invitations", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, ListInvitationsResponse{Invitations: []Invitation{{ID: "i1", Email: "a@b.com"}}}) + }}, + {"POST", "/api/v1/teams/t1/invitations", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["email"] != "a@b.com" || body["role"] != "maintainer" { + t.Fatalf("body = %v", body) + } + writeJSON(t, w, http.StatusCreated, CreateInvitationResponse{Invitation: Invitation{ID: "i1", Email: "a@b.com"}, Token: "inv_x"}) + }}, + {"DELETE", "/api/v1/teams/t1/invitations/i1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, RevokeInvitationResponse{Message: "invitation revoked"}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + lst, err := c.Teams().ListInvitations(context.Background(), "t1") + if err != nil || len(lst.Invitations) != 1 { + t.Fatalf("list err=%v", err) + } + created, err := c.Teams().CreateInvitation(context.Background(), "t1", "a@b.com", "maintainer") + if err != nil || created.Token != "inv_x" { + t.Fatalf("create err=%v out=%+v", err, created) + } + if _, err := c.Teams().RevokeInvitation(context.Background(), "t1", "i1"); err != nil { + t.Fatal(err) + } +} + +// ── Sharding ────────────────────────────────────────────────────────────────── + +func TestSharding_All(t *testing.T) { + srv := newTestServer(t, []route{ + {"POST", "/api/v1/sharding/plan", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["num_workers"] != float64(2) { + t.Fatalf("num_workers = %v", body["num_workers"]) + } + writeJSON(t, w, http.StatusOK, ShardPlan{ExecutionID: "e1", TotalWorkers: 2, Strategy: "duration_balanced"}) + }}, + {"POST", "/api/v1/sharding/rebalance", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["failed_worker_id"] != "w1" { + t.Fatalf("failed_worker_id = %v", body["failed_worker_id"]) + } + writeJSON(t, w, http.StatusOK, ShardPlan{ExecutionID: "e1", TotalWorkers: 1}) + }}, + {"GET", "/api/v1/sharding/durations", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + mustHaveQuery(t, r, "suite", "api") + writeJSON(t, w, http.StatusOK, ListShardDurationsResponse{Durations: []TestDurationHistory{{ID: "d1", TestName: "t1"}}, Total: 1}) + }}, + {"GET", "/api/v1/sharding/durations/t1", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, []TestDurationHistory{{ID: "d1", TestName: "t1"}}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + + plan, err := c.Sharding().CreatePlan(context.Background(), &CreateShardPlanRequest{TestNames: []string{"t1", "t2"}, NumWorkers: 2}) + if err != nil || plan.TotalWorkers != 2 { + t.Fatalf("plan err=%v out=%+v", err, plan) + } + reb, err := c.Sharding().Rebalance(context.Background(), &RebalanceShardsRequest{ExecutionID: "e1", FailedWorkerID: "w1", CurrentPlan: ShardPlan{ExecutionID: "e1"}}) + if err != nil || reb.TotalWorkers != 1 { + t.Fatalf("rebalance err=%v out=%+v", err, reb) + } + durs, err := c.Sharding().ListDurations(context.Background(), "api") + if err != nil || durs.Total != 1 { + t.Fatalf("durations err=%v", err) + } + one, err := c.Sharding().GetDuration(context.Background(), "t1") + if err != nil || len(one) != 1 { + t.Fatalf("get-duration err=%v out=%+v", err, one) + } + if _, err := c.Sharding().CreatePlan(context.Background(), &CreateShardPlanRequest{NumWorkers: 2}); err == nil { + t.Fatal("expected error for empty test_names") + } + if _, err := c.Sharding().CreatePlan(context.Background(), &CreateShardPlanRequest{TestNames: []string{"t1"}}); err == nil { + t.Fatal("expected error for zero workers") + } +} + +// ── Auth ────────────────────────────────────────────────────────────────────── + +func TestAuth_Register_Login(t *testing.T) { + srv := newTestServer(t, []route{ + {"POST", "/auth/register", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustNoAuth(t, r) + body := decodeBody(t, r) + if body["email"] != "a@b.com" { + t.Fatalf("email = %v", body["email"]) + } + writeJSON(t, w, http.StatusCreated, AuthResponse{User: UserProfile{ID: "u1", Email: "a@b.com"}, AccessToken: "access-1"}) + }}, + {"POST", "/auth/login", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustNoAuth(t, r) + writeJSON(t, w, http.StatusOK, AuthResponse{User: UserProfile{ID: "u1"}, AccessToken: "access-1"}) + }}, + {"POST", "/auth/refresh", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + // Refresh uses the refresh token in place of the access token for this test. + if got := r.Header.Get("Authorization"); got != "Bearer refresh-1" { + t.Fatalf("Authorization = %q, want Bearer refresh-1", got) + } + writeJSON(t, w, http.StatusOK, RefreshTokenResponse{User: UserProfile{ID: "u1"}, AccessToken: "access-2"}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("ignored-for-register")) + + reg, err := c.Auth().Register(context.Background(), &RegisterRequest{Email: "a@b.com", Password: "password1", DisplayName: "Alice"}) + if err != nil || reg.AccessToken != "access-1" { + t.Fatalf("register err=%v out=%+v", err, reg) + } + login, err := c.Auth().Login(context.Background(), &LoginRequest{Email: "a@b.com", Password: "password1"}) + if err != nil || login.AccessToken != "access-1" { + t.Fatalf("login err=%v out=%+v", err, login) + } + // RefreshWithToken sends the refresh token as the bearer for this single + // request and must not mutate the client's configured token. + ref, err := c.Auth().RefreshWithToken(context.Background(), "refresh-1") + if err != nil || ref.AccessToken != "access-2" { + t.Fatalf("refresh err=%v out=%+v", err, ref) + } + if c.token != "ignored-for-register" { + t.Fatalf("client token mutated by RefreshWithToken: %q", c.token) + } + if _, err := c.Auth().Register(context.Background(), &RegisterRequest{Email: "a@b.com"}); err == nil { + t.Fatal("expected error for missing password") + } +} + +func TestAuth_Me_AndProfile(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/auth/me", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + writeJSON(t, w, http.StatusOK, UserProfile{ID: "u1", Email: "a@b.com", DisplayName: "Alice", Role: "owner"}) + }}, + {"PATCH", "/api/v1/auth/me", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["display_name"] != "Bob" { + t.Fatalf("display_name = %v", body["display_name"]) + } + writeJSON(t, w, http.StatusOK, UserProfile{ID: "u1", DisplayName: "Bob"}) + }}, + {"POST", "/api/v1/auth/change-password", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + body := decodeBody(t, r) + if body["new_password"] != "newpass1" { + t.Fatalf("new_password = %v", body["new_password"]) + } + writeJSON(t, w, http.StatusOK, ChangePasswordResponse{Message: "password changed"}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + me, err := c.Auth().GetMe(context.Background()) + if err != nil || me.DisplayName != "Alice" { + t.Fatalf("me err=%v out=%+v", err, me) + } + upd, err := c.Auth().UpdateProfile(context.Background(), "Bob") + if err != nil || upd.DisplayName != "Bob" { + t.Fatalf("update err=%v out=%+v", err, upd) + } + pw, err := c.Auth().ChangePassword(context.Background(), "oldpass1", "newpass1") + if err != nil || pw.Message != "password changed" { + t.Fatalf("change-password err=%v out=%+v", err, pw) + } +} + +// ── Admin ───────────────────────────────────────────────────────────────────── + +func TestAdmin(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/admin/users", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + mustHaveQuery(t, r, "limit", "10") + writeJSON(t, w, http.StatusOK, ListUsersResponse{Users: []AdminUser{{ID: "u1"}}, Total: 1}) + }}, + {"GET", "/api/v1/admin/audit-log", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustAuth(t, r, "tok") + mustHaveQuery(t, r, "action", "report.submitted") + writeJSON(t, w, http.StatusOK, ListAuditLogResponse{AuditLog: []AuditLog{{ID: "a1", Action: "report.submitted"}}, Total: 1}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + users, err := c.Admin().ListUsers(context.Background(), &ListUsersParams{Limit: 10}) + if err != nil || users.Total != 1 { + t.Fatalf("users err=%v", err) + } + audit, err := c.Admin().ListAuditLog(context.Background(), &ListAuditLogParams{Action: "report.submitted"}) + if err != nil || audit.Total != 1 { + t.Fatalf("audit err=%v", err) + } +} + +// ── Health ──────────────────────────────────────────────────────────────────── + +func TestHealth_Check(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/health", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustNoAuth(t, r) + writeJSON(t, w, http.StatusOK, HealthResponse{Status: "ok", Timestamp: time.Now()}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL, WithToken("tok")) + h, err := c.Health().Check(context.Background()) + if err != nil || h.Status != "ok" { + t.Fatalf("health err=%v out=%+v", err, h) + } +} + +// ── Invitations (public) ────────────────────────────────────────────────────── + +func TestInvitations_Public(t *testing.T) { + srv := newTestServer(t, []route{ + {"GET", "/api/v1/invitations/inv_x", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustNoAuth(t, r) + writeJSON(t, w, http.StatusOK, InvitationPreview{Email: "a@b.com", Role: "maintainer", TeamName: "team1"}) + }}, + {"POST", "/api/v1/invitations/inv_x/accept", func(t *testing.T, w http.ResponseWriter, r *http.Request) { + mustNoAuth(t, r) + body := decodeBody(t, r) + if body["display_name"] != "Alice" { + t.Fatalf("display_name = %v", body["display_name"]) + } + writeJSON(t, w, http.StatusOK, AcceptInvitationResponse{Message: "invitation accepted", UserID: "u1", TeamID: "t1", Role: "maintainer"}) + }}, + }) + defer srv.Close() + c, _ := NewClient(srv.URL) + prev, err := c.Invitations().Preview(context.Background(), "inv_x") + if err != nil || prev.TeamName != "team1" { + t.Fatalf("preview err=%v out=%+v", err, prev) + } + acc, err := c.Invitations().Accept(context.Background(), "inv_x", &AcceptInvitationRequest{Password: "password1", DisplayName: "Alice"}) + if err != nil || acc.UserID != "u1" { + t.Fatalf("accept err=%v out=%+v", err, acc) + } + if _, err := c.Invitations().Preview(context.Background(), ""); err == nil { + t.Fatal("expected error for empty token") + } +} + +// ── Query helpers ───────────────────────────────────────────────────────────── + +func TestAddQuery_SkipsEmpty(t *testing.T) { + q := addQuery(nil, "a", "") + if q != nil { + t.Fatalf("expected nil for empty value, got %v", q) + } + q = addQuery(nil, "a", "v") + if q.Get("a") != "v" { + t.Fatalf("expected a=v, got %v", q) + } +} + +func TestAddInt_SkipsNonPositive(t *testing.T) { + if q := addInt(nil, "limit", 0); q != nil { + t.Fatalf("expected nil for zero, got %v", q) + } + if q := addInt(nil, "limit", -1); q != nil { + t.Fatalf("expected nil for negative, got %v", q) + } +} + +func TestPathEscape(t *testing.T) { + // Verify path segments with slashes are escaped so path injection cannot + // escape the resource scope. + got := pathEscape("a/b") + want := url.PathEscape("a/b") + if got != want { + t.Fatalf("pathEscape = %q, want %q", got, want) + } +} diff --git a/sdk/go/doc.go b/sdk/go/doc.go new file mode 100644 index 00000000..ea0115d7 --- /dev/null +++ b/sdk/go/doc.go @@ -0,0 +1,17 @@ +// Package scaledtest provides a Go client for the ScaledTest API. +// +// Usage: +// +// client, err := scaledtest.NewClient("https://your-instance.example.com", +// scaledtest.WithToken("sct_your_api_token"), +// ) +// if err != nil { +// log.Fatal(err) +// } +// reports, err := client.Reports.List(context.Background(), nil) +// +// All API routes are under /api/v1/. Authentication uses Bearer tokens (JWT or +// sct_ API tokens) via the Authorization header. The client has no external +// dependencies beyond the Go standard library, matching the ScaledTest +// backend's minimal dependency philosophy. +package scaledtest diff --git a/sdk/go/errors.go b/sdk/go/errors.go new file mode 100644 index 00000000..e88eb47d --- /dev/null +++ b/sdk/go/errors.go @@ -0,0 +1,49 @@ +package scaledtest + +import ( + "errors" + "fmt" +) + +// ScaledTestError is returned by the API for any non-2xx response. It carries +// the HTTP status code, the server-supplied error code (if present), and the +// human-readable message extracted from the response body. +type ScaledTestError struct { + Status int + Code string + Message string +} + +// Error implements the error interface. +func (e *ScaledTestError) Error() string { + if e.Code != "" { + return fmt.Sprintf("scaledtest: %s (status %d, code %q)", e.Message, e.Status, e.Code) + } + return fmt.Sprintf("scaledtest: %s (status %d)", e.Message, e.Status) +} + +// IsScaledTestError reports whether err is a *ScaledTestError. +func IsScaledTestError(err error) bool { + var ste *ScaledTestError + return errors.As(err, &ste) +} + +// AsScaledTestError returns err as a *ScaledTestError and true if it is one, +// otherwise nil and false. +func AsScaledTestError(err error) (*ScaledTestError, bool) { + var ste *ScaledTestError + if errors.As(err, &ste) { + return ste, true + } + return nil, false +} + +// errorEnvelope is the JSON shape returned by the ScaledTest error helper: +// +// {"error": "...", "code": "..."} +// +// The code field is optional and only present for a subset of endpoints. +type errorEnvelope struct { + Error string `json:"error"` + Code string `json:"code,omitempty"` +} diff --git a/sdk/go/executions.go b/sdk/go/executions.go new file mode 100644 index 00000000..0c07b1d0 --- /dev/null +++ b/sdk/go/executions.go @@ -0,0 +1,150 @@ +package scaledtest + +import ( + "context" + "errors" + "net/url" +) + +// ExecutionsService exposes the /api/v1/executions endpoints. +type ExecutionsService struct { + client *Client +} + +// ListExecutionsParams filters the Executions.List result set. +type ListExecutionsParams struct { + Limit int + Offset int +} + +// CreateExecutionOptions carries optional fields for Create. +type CreateExecutionOptions struct { + Image string + EnvVars map[string]string +} + +// List retrieves a paginated list of executions for the caller's team. +func (s *ExecutionsService) List(ctx context.Context, p *ListExecutionsParams) (*ListExecutionsResponse, error) { + var q url.Values + if p != nil { + q = addInt(q, "limit", p.Limit) + q = addInt(q, "offset", p.Offset) + } + var out ListExecutionsResponse + if err := s.client.doAPI(ctx, "GET", "/executions", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Create starts a new test execution. +func (s *ExecutionsService) Create(ctx context.Context, command string, opts *CreateExecutionOptions) (*CreateExecutionResponse, error) { + if command == "" { + return nil, errors.New("scaledtest: command is required") + } + body := map[string]interface{}{"command": command} + if opts != nil { + if opts.Image != "" { + body["image"] = opts.Image + } + if len(opts.EnvVars) > 0 { + body["env_vars"] = opts.EnvVars + } + } + var out CreateExecutionResponse + if err := s.client.doAPI(ctx, "POST", "/executions", nil, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Get retrieves a single execution by ID. +func (s *ExecutionsService) Get(ctx context.Context, id string) (*Execution, error) { + if id == "" { + return nil, errMissingID("execution") + } + var out Execution + if err := s.client.doAPI(ctx, "GET", "/executions/"+pathEscape(id), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Cancel cancels (and deletes the K8s job for) an execution. It is an alias +// for Delete on the underlying endpoint. +func (s *ExecutionsService) Cancel(ctx context.Context, id string) (*CancelExecutionResponse, error) { + if id == "" { + return nil, errMissingID("execution") + } + var out CancelExecutionResponse + if err := s.client.doAPI(ctx, "DELETE", "/executions/"+pathEscape(id), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Delete is an alias for Cancel. +func (s *ExecutionsService) Delete(ctx context.Context, id string) (*CancelExecutionResponse, error) { + return s.Cancel(ctx, id) +} + +// UpdateStatus updates the lifecycle status of an execution (worker callback). +func (s *ExecutionsService) UpdateStatus(ctx context.Context, id string, status UpdateExecutionStatus, errorMsg string) (*UpdateExecutionStatusResponse, error) { + if id == "" { + return nil, errMissingID("execution") + } + body := map[string]interface{}{"status": string(status)} + if errorMsg != "" { + body["error_msg"] = errorMsg + } + var out UpdateExecutionStatusResponse + if err := s.client.doAPI(ctx, "PUT", "/executions/"+pathEscape(id)+"/status", nil, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ReportProgress streams live test counters for an execution (worker callback). +func (s *ExecutionsService) ReportProgress(ctx context.Context, id string, progress *ExecutionProgress) (*ExecutionProgressResponse, error) { + if id == "" { + return nil, errMissingID("execution") + } + if progress == nil { + return nil, errors.New("scaledtest: progress is required") + } + var out ExecutionProgressResponse + if err := s.client.doAPI(ctx, "POST", "/executions/"+pathEscape(id)+"/progress", nil, progress, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ReportTestResult streams a single test result for an execution (worker callback). +func (s *ExecutionsService) ReportTestResult(ctx context.Context, id string, result *TestResultEvent) (*ExecutionReceivedResponse, error) { + if id == "" { + return nil, errMissingID("execution") + } + if result == nil { + return nil, errors.New("scaledtest: test result is required") + } + var out ExecutionReceivedResponse + if err := s.client.doAPI(ctx, "POST", "/executions/"+pathEscape(id)+"/test-result", nil, result, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ReportWorkerStatus streams worker health for an execution (worker callback). +func (s *ExecutionsService) ReportWorkerStatus(ctx context.Context, id string, status *WorkerStatusEvent) (*ExecutionReceivedResponse, error) { + if id == "" { + return nil, errMissingID("execution") + } + if status == nil { + return nil, errors.New("scaledtest: worker status is required") + } + var out ExecutionReceivedResponse + if err := s.client.doAPI(ctx, "POST", "/executions/"+pathEscape(id)+"/worker-status", nil, status, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/sdk/go/health.go b/sdk/go/health.go new file mode 100644 index 00000000..c102626d --- /dev/null +++ b/sdk/go/health.go @@ -0,0 +1,19 @@ +package scaledtest + +import ( + "context" +) + +// HealthService exposes the public /health endpoint. +type HealthService struct { + client *Client +} + +// Check queries the server health endpoint. No authentication is required. +func (s *HealthService) Check(ctx context.Context) (*HealthResponse, error) { + var out HealthResponse + if err := s.client.doRaw(ctx, "GET", "/health", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/sdk/go/invitations.go b/sdk/go/invitations.go new file mode 100644 index 00000000..9cc29da2 --- /dev/null +++ b/sdk/go/invitations.go @@ -0,0 +1,46 @@ +package scaledtest + +import ( + "context" + "errors" +) + +// InvitationsService exposes the public, token-scoped invitation endpoints +// under /api/v1/invitations/{token}. No bearer token is required; the token +// in the URL path authenticates the request. +type InvitationsService struct { + client *Client +} + +// Preview returns the details of a pending invitation by token. +func (s *InvitationsService) Preview(ctx context.Context, token string) (*InvitationPreview, error) { + if token == "" { + return nil, errors.New("scaledtest: invitation token is required") + } + var out InvitationPreview + if err := s.client.doRaw(ctx, "GET", "/api/v1/invitations/"+pathEscape(token), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// AcceptInvitationRequest is the body for Accept. +type AcceptInvitationRequest struct { + Password string `json:"password"` + DisplayName string `json:"display_name"` +} + +// Accept creates a user account and adds them to the inviting team. +func (s *InvitationsService) Accept(ctx context.Context, token string, req *AcceptInvitationRequest) (*AcceptInvitationResponse, error) { + if token == "" { + return nil, errors.New("scaledtest: invitation token is required") + } + if req == nil || req.Password == "" || req.DisplayName == "" { + return nil, errors.New("scaledtest: password and display_name are required") + } + var out AcceptInvitationResponse + if err := s.client.doRaw(ctx, "POST", "/api/v1/invitations/"+pathEscape(token)+"/accept", nil, req, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/sdk/go/quality_gates.go b/sdk/go/quality_gates.go new file mode 100644 index 00000000..2a27e079 --- /dev/null +++ b/sdk/go/quality_gates.go @@ -0,0 +1,153 @@ +package scaledtest + +import ( + "context" + "errors" +) + +// QualityGatesService exposes the /api/v1/teams/{teamID}/quality-gates endpoints. +type QualityGatesService struct { + client *Client +} + +// List retrieves all quality gates for a team. +func (s *QualityGatesService) List(ctx context.Context, teamID string) (*ListQualityGatesResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + var out ListQualityGatesResponse + if err := s.client.doAPI(ctx, "GET", "/teams/"+pathEscape(teamID)+"/quality-gates", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateQualityGateParams is the body for Create. +type CreateQualityGateParams struct { + Name string + Description string + Rules []QualityGateRule +} + +// Create creates a new quality gate for a team. +func (s *QualityGatesService) Create(ctx context.Context, teamID string, params *CreateQualityGateParams) (*QualityGate, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if params == nil || params.Name == "" { + return nil, errors.New("scaledtest: quality gate name is required") + } + if len(params.Rules) == 0 { + return nil, errors.New("scaledtest: quality gate rules must not be empty") + } + body := map[string]interface{}{"name": params.Name, "rules": params.Rules} + if params.Description != "" { + body["description"] = params.Description + } + var out QualityGate + if err := s.client.doAPI(ctx, "POST", "/teams/"+pathEscape(teamID)+"/quality-gates", nil, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Get retrieves a single quality gate by ID. +func (s *QualityGatesService) Get(ctx context.Context, teamID, id string) (*QualityGate, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if id == "" { + return nil, errMissingID("quality gate") + } + var out QualityGate + if err := s.client.doAPI(ctx, "GET", "/teams/"+pathEscape(teamID)+"/quality-gates/"+pathEscape(id), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateQualityGateParams is the body for Update. +type UpdateQualityGateParams struct { + Name string + Description string + Rules []QualityGateRule + Enabled *bool +} + +// Update modifies a quality gate. +func (s *QualityGatesService) Update(ctx context.Context, teamID, id string, params *UpdateQualityGateParams) (*QualityGate, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if id == "" { + return nil, errMissingID("quality gate") + } + if params == nil || params.Name == "" { + return nil, errors.New("scaledtest: quality gate name is required") + } + if len(params.Rules) == 0 { + return nil, errors.New("scaledtest: quality gate rules must not be empty") + } + body := map[string]interface{}{"name": params.Name, "rules": params.Rules} + if params.Description != "" { + body["description"] = params.Description + } + if params.Enabled != nil { + body["enabled"] = *params.Enabled + } + var out QualityGate + if err := s.client.doAPI(ctx, "PUT", "/teams/"+pathEscape(teamID)+"/quality-gates/"+pathEscape(id), nil, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Delete removes a quality gate. +func (s *QualityGatesService) Delete(ctx context.Context, teamID, id string) (*DeleteQualityGateResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if id == "" { + return nil, errMissingID("quality gate") + } + var out DeleteQualityGateResponse + if err := s.client.doAPI(ctx, "DELETE", "/teams/"+pathEscape(teamID)+"/quality-gates/"+pathEscape(id), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Evaluate runs a quality gate against a report. +func (s *QualityGatesService) Evaluate(ctx context.Context, teamID, id, reportID string) (*EvaluateQualityGateResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if id == "" { + return nil, errMissingID("quality gate") + } + if reportID == "" { + return nil, errMissingID("report") + } + body := map[string]string{"report_id": reportID} + var out EvaluateQualityGateResponse + if err := s.client.doAPI(ctx, "POST", "/teams/"+pathEscape(teamID)+"/quality-gates/"+pathEscape(id)+"/evaluate", nil, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListEvaluations returns recent evaluation records for a gate. +func (s *QualityGatesService) ListEvaluations(ctx context.Context, teamID, id string, limit int) (*ListEvaluationsResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if id == "" { + return nil, errMissingID("quality gate") + } + q := addInt(nil, "limit", limit) + var out ListEvaluationsResponse + if err := s.client.doAPI(ctx, "GET", "/teams/"+pathEscape(teamID)+"/quality-gates/"+pathEscape(id)+"/evaluations", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/sdk/go/reports.go b/sdk/go/reports.go new file mode 100644 index 00000000..026b9217 --- /dev/null +++ b/sdk/go/reports.go @@ -0,0 +1,118 @@ +package scaledtest + +import ( + "context" + "net/url" +) + +// ReportsService exposes the /api/v1/reports endpoints. +type ReportsService struct { + client *Client +} + +// ListReportsParams filters the Reports.List result set. +type ListReportsParams struct { + Limit int + Offset int + Since string // RFC3339 + Until string // RFC3339 +} + +// UploadReportParams controls optional upload behaviour. +type UploadReportParams struct { + ExecutionID string + TriageGitHubStatus bool +} + +// List retrieves a paginated list of reports for the caller's team. +func (s *ReportsService) List(ctx context.Context, p *ListReportsParams) (*ListReportsResponse, error) { + var q url.Values + if p != nil { + q = addInt(q, "limit", p.Limit) + q = addInt(q, "offset", p.Offset) + q = addQuery(q, "since", p.Since) + q = addQuery(q, "until", p.Until) + } + var out ListReportsResponse + if err := s.client.doAPI(ctx, "GET", "/reports", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Upload submits a CTRF report for ingestion. +func (s *ReportsService) Upload(ctx context.Context, report *CtrfReport, p *UploadReportParams) (*UploadReportResponse, error) { + var q url.Values + if p != nil { + q = addQuery(q, "execution_id", p.ExecutionID) + q = addBool(q, "triage_github_status", p.TriageGitHubStatus) + } + var out UploadReportResponse + if err := s.client.doAPI(ctx, "POST", "/reports", q, report, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Get retrieves a single report by ID. +func (s *ReportsService) Get(ctx context.Context, id string) (*Report, error) { + if id == "" { + return nil, errMissingID("report") + } + var out Report + if err := s.client.doAPI(ctx, "GET", "/reports/"+pathEscape(id), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Delete removes a report by ID. +func (s *ReportsService) Delete(ctx context.Context, id string) (*DeleteReportResponse, error) { + if id == "" { + return nil, errMissingID("report") + } + var out DeleteReportResponse + if err := s.client.doAPI(ctx, "DELETE", "/reports/"+pathEscape(id), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Compare diffs two reports by ID. +func (s *ReportsService) Compare(ctx context.Context, baseID, headID string) (*ReportCompareResult, error) { + if baseID == "" || headID == "" { + return nil, errMissingID("report") + } + q := url.Values{} + q.Set("base", baseID) + q.Set("head", headID) + var out ReportCompareResult + if err := s.client.doAPI(ctx, "GET", "/reports/compare", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetTriage retrieves the persisted triage result for a report. +func (s *ReportsService) GetTriage(ctx context.Context, reportID string) (*ReportTriageResult, error) { + if reportID == "" { + return nil, errMissingID("report") + } + var out ReportTriageResult + if err := s.client.doAPI(ctx, "GET", "/reports/"+pathEscape(reportID)+"/triage", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// RetryTriage re-triggers LLM triage for a report. +func (s *ReportsService) RetryTriage(ctx context.Context, reportID string) (*RetryTriageResponse, error) { + if reportID == "" { + return nil, errMissingID("report") + } + var out RetryTriageResponse + if err := s.client.doAPI(ctx, "POST", "/reports/"+pathEscape(reportID)+"/triage/retry", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/sdk/go/sharding.go b/sdk/go/sharding.go new file mode 100644 index 00000000..c19c8af7 --- /dev/null +++ b/sdk/go/sharding.go @@ -0,0 +1,70 @@ +package scaledtest + +import ( + "context" + "errors" +) + +// ShardingService exposes the /api/v1/sharding endpoints. +type ShardingService struct { + client *Client +} + +// CreatePlan computes a shard plan for the given tests. +func (s *ShardingService) CreatePlan(ctx context.Context, req *CreateShardPlanRequest) (*ShardPlan, error) { + if req == nil { + return nil, errors.New("scaledtest: shard plan request is required") + } + if len(req.TestNames) == 0 { + return nil, errors.New("scaledtest: test_names must not be empty") + } + if req.NumWorkers <= 0 { + return nil, errors.New("scaledtest: num_workers must be positive") + } + var out ShardPlan + if err := s.client.doAPI(ctx, "POST", "/sharding/plan", nil, req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Rebalance redistributes tests from a failed worker to remaining workers. +func (s *ShardingService) Rebalance(ctx context.Context, req *RebalanceShardsRequest) (*ShardPlan, error) { + if req == nil { + return nil, errors.New("scaledtest: rebalance request is required") + } + if req.ExecutionID == "" { + return nil, errMissingID("execution") + } + if req.FailedWorkerID == "" { + return nil, errors.New("scaledtest: failed_worker_id is required") + } + var out ShardPlan + if err := s.client.doAPI(ctx, "POST", "/sharding/rebalance", nil, req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListDurations returns historical duration data for the team, optionally +// filtered by suite. +func (s *ShardingService) ListDurations(ctx context.Context, suite string) (*ListShardDurationsResponse, error) { + q := addQuery(nil, "suite", suite) + var out ListShardDurationsResponse + if err := s.client.doAPI(ctx, "GET", "/sharding/durations", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetDuration returns duration history entries for a single test name. +func (s *ShardingService) GetDuration(ctx context.Context, testName string) ([]TestDurationHistory, error) { + if testName == "" { + return nil, errors.New("scaledtest: test name is required") + } + var out []TestDurationHistory + if err := s.client.doAPI(ctx, "GET", "/sharding/durations/"+pathEscape(testName), nil, nil, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/sdk/go/teams.go b/sdk/go/teams.go new file mode 100644 index 00000000..377c0299 --- /dev/null +++ b/sdk/go/teams.go @@ -0,0 +1,293 @@ +package scaledtest + +import ( + "context" + "errors" + "net/url" +) + +// TeamsService exposes the /api/v1/teams endpoints, including the tokens, +// webhooks, and invitations sub-resources. +type TeamsService struct { + client *Client +} + +// List returns the teams the caller belongs to. +func (s *TeamsService) List(ctx context.Context) (*ListTeamsResponse, error) { + var out ListTeamsResponse + if err := s.client.doAPI(ctx, "GET", "/teams", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Create creates a new team. +func (s *TeamsService) Create(ctx context.Context, name string) (*Team, error) { + if name == "" { + return nil, errors.New("scaledtest: team name is required") + } + var out Team + if err := s.client.doAPI(ctx, "POST", "/teams", nil, map[string]string{"name": name}, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Get retrieves a team and the caller's role in it. +func (s *TeamsService) Get(ctx context.Context, id string) (*GetTeamResponse, error) { + if id == "" { + return nil, errMissingID("team") + } + var out GetTeamResponse + if err := s.client.doAPI(ctx, "GET", "/teams/"+pathEscape(id), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Delete removes a team (owner-only). +func (s *TeamsService) Delete(ctx context.Context, id string) (*DeleteTeamResponse, error) { + if id == "" { + return nil, errMissingID("team") + } + var out DeleteTeamResponse + if err := s.client.doAPI(ctx, "DELETE", "/teams/"+pathEscape(id), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ── Tokens ─────────────────────────────────────────────────────────────────── + +// ListTokens returns the API tokens for a team. +func (s *TeamsService) ListTokens(ctx context.Context, teamID string) (*ListTokensResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + var out ListTokensResponse + if err := s.client.doAPI(ctx, "GET", "/teams/"+pathEscape(teamID)+"/tokens", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateToken creates a new API token for a team. The full token value is +// only returned once, in the response. +func (s *TeamsService) CreateToken(ctx context.Context, teamID, name string) (*CreateTokenResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if name == "" { + return nil, errors.New("scaledtest: token name is required") + } + var out CreateTokenResponse + if err := s.client.doAPI(ctx, "POST", "/teams/"+pathEscape(teamID)+"/tokens", nil, map[string]string{"name": name}, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteToken revokes an API token. +func (s *TeamsService) DeleteToken(ctx context.Context, teamID, tokenID string) (*DeleteTokenResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if tokenID == "" { + return nil, errMissingID("token") + } + var out DeleteTokenResponse + if err := s.client.doAPI(ctx, "DELETE", "/teams/"+pathEscape(teamID)+"/tokens/"+pathEscape(tokenID), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ── Webhooks ───────────────────────────────────────────────────────────────── + +// ListWebhooks returns the webhooks for a team. +func (s *TeamsService) ListWebhooks(ctx context.Context, teamID string) (*ListWebhooksResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + var out ListWebhooksResponse + if err := s.client.doAPI(ctx, "GET", "/teams/"+pathEscape(teamID)+"/webhooks", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateWebhook creates a new webhook. The signing secret is only returned +// once, in the response. +func (s *TeamsService) CreateWebhook(ctx context.Context, teamID, webhookURL string, events []WebhookEventType) (*CreateWebhookResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if webhookURL == "" { + return nil, errors.New("scaledtest: webhook url is required") + } + if len(events) == 0 { + return nil, errors.New("scaledtest: webhook events must not be empty") + } + body := map[string]interface{}{"url": webhookURL, "events": events} + var out CreateWebhookResponse + if err := s.client.doAPI(ctx, "POST", "/teams/"+pathEscape(teamID)+"/webhooks", nil, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetWebhook retrieves a single webhook. +func (s *TeamsService) GetWebhook(ctx context.Context, teamID, webhookID string) (*Webhook, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if webhookID == "" { + return nil, errMissingID("webhook") + } + var out Webhook + if err := s.client.doAPI(ctx, "GET", "/teams/"+pathEscape(teamID)+"/webhooks/"+pathEscape(webhookID), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateWebhookParams is the body for UpdateWebhook. +type UpdateWebhookParams struct { + URL string + Events []WebhookEventType + Enabled *bool +} + +// UpdateWebhook modifies a webhook. +func (s *TeamsService) UpdateWebhook(ctx context.Context, teamID, webhookID string, params *UpdateWebhookParams) (*Webhook, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if webhookID == "" { + return nil, errMissingID("webhook") + } + if params == nil || params.URL == "" { + return nil, errors.New("scaledtest: webhook url is required") + } + if len(params.Events) == 0 { + return nil, errors.New("scaledtest: webhook events must not be empty") + } + body := map[string]interface{}{"url": params.URL, "events": params.Events} + if params.Enabled != nil { + body["enabled"] = *params.Enabled + } + var out Webhook + if err := s.client.doAPI(ctx, "PUT", "/teams/"+pathEscape(teamID)+"/webhooks/"+pathEscape(webhookID), nil, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteWebhook removes a webhook. +func (s *TeamsService) DeleteWebhook(ctx context.Context, teamID, webhookID string) (*DeleteWebhookResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if webhookID == "" { + return nil, errMissingID("webhook") + } + var out DeleteWebhookResponse + if err := s.client.doAPI(ctx, "DELETE", "/teams/"+pathEscape(teamID)+"/webhooks/"+pathEscape(webhookID), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListWebhookDeliveriesParams filters the deliveries listing. +type ListWebhookDeliveriesParams struct { + BeforeID string + Limit int +} + +// ListWebhookDeliveries returns delivery history for a webhook. +func (s *TeamsService) ListWebhookDeliveries(ctx context.Context, teamID, webhookID string, p *ListWebhookDeliveriesParams) (*ListWebhookDeliveriesResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if webhookID == "" { + return nil, errMissingID("webhook") + } + var q url.Values + if p != nil { + q = addQuery(q, "before_id", p.BeforeID) + q = addInt(q, "limit", p.Limit) + } + var out ListWebhookDeliveriesResponse + if err := s.client.doAPI(ctx, "GET", "/teams/"+pathEscape(teamID)+"/webhooks/"+pathEscape(webhookID)+"/deliveries", q, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// RetryWebhookDelivery re-dispatches a stored webhook delivery. +func (s *TeamsService) RetryWebhookDelivery(ctx context.Context, teamID, webhookID, deliveryID string) (*RetryWebhookDeliveryResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if webhookID == "" { + return nil, errMissingID("webhook") + } + if deliveryID == "" { + return nil, errMissingID("delivery") + } + var out RetryWebhookDeliveryResponse + path := "/teams/" + pathEscape(teamID) + "/webhooks/" + pathEscape(webhookID) + "/deliveries/" + pathEscape(deliveryID) + "/retry" + if err := s.client.doAPI(ctx, "POST", path, nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ── Invitations (team-scoped) ───────────────────────────────────────────────── + +// ListInvitations returns pending invitations for a team. +func (s *TeamsService) ListInvitations(ctx context.Context, teamID string) (*ListInvitationsResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + var out ListInvitationsResponse + if err := s.client.doAPI(ctx, "GET", "/teams/"+pathEscape(teamID)+"/invitations", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateInvitation creates a new team invitation. The token is only returned +// once, in the response. +func (s *TeamsService) CreateInvitation(ctx context.Context, teamID, email, role string) (*CreateInvitationResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if email == "" { + return nil, errors.New("scaledtest: invitation email is required") + } + if role == "" { + return nil, errors.New("scaledtest: invitation role is required") + } + body := map[string]string{"email": email, "role": role} + var out CreateInvitationResponse + if err := s.client.doAPI(ctx, "POST", "/teams/"+pathEscape(teamID)+"/invitations", nil, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// RevokeInvitation revokes a pending invitation. +func (s *TeamsService) RevokeInvitation(ctx context.Context, teamID, invitationID string) (*RevokeInvitationResponse, error) { + if teamID == "" { + return nil, errMissingID("team") + } + if invitationID == "" { + return nil, errMissingID("invitation") + } + var out RevokeInvitationResponse + if err := s.client.doAPI(ctx, "DELETE", "/teams/"+pathEscape(teamID)+"/invitations/"+pathEscape(invitationID), nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/sdk/go/types.go b/sdk/go/types.go new file mode 100644 index 00000000..937e4cd8 --- /dev/null +++ b/sdk/go/types.go @@ -0,0 +1,800 @@ +package scaledtest + +import ( + "encoding/json" + "time" +) + +// ── Reports ────────────────────────────────────────────────────────────────── + +// CtrfReport is the CTRF report payload submitted to UploadReport. The +// structure mirrors the CTRF specification as accepted by the ScaledTest +// ingest endpoint. +type CtrfReport struct { + Results CtrfResults `json:"results"` +} + +// CtrfResults is the top-level results container of a CTRF report. +type CtrfResults struct { + Tool CtrfTool `json:"tool"` + Environment map[string]interface{} `json:"environment,omitempty"` + Summary CtrfSummary `json:"summary"` + Tests []CtrfTest `json:"tests"` +} + +// CtrfTool identifies the test runner that produced the report. +type CtrfTool struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +// CtrfSummary holds the aggregate counts for a CTRF report. +type CtrfSummary struct { + Tests int `json:"tests"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Pending int `json:"pending"` + Other int `json:"other"` + Start int64 `json:"start,omitempty"` + Stop int64 `json:"stop,omitempty"` +} + +// CtrfTest is a single test entry within a CTRF report. +type CtrfTest struct { + Name string `json:"name"` + Status string `json:"status"` + Duration int64 `json:"duration"` + Message string `json:"message,omitempty"` + Trace string `json:"trace,omitempty"` + Suite string `json:"suite,omitempty"` + Tags []string `json:"tags,omitempty"` + Flaky bool `json:"flaky,omitempty"` + Retry int `json:"retry,omitempty"` + FilePath string `json:"filePath,omitempty"` +} + +// Report is the API representation of a stored test report. +type Report struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + ToolName string `json:"tool_name,omitempty"` + ToolVersion string `json:"tool_version,omitempty"` + Summary json.RawMessage `json:"summary"` + TestCount int `json:"test_count,omitempty"` + Passed int `json:"passed,omitempty"` + Failed int `json:"failed,omitempty"` + Skipped int `json:"skipped,omitempty"` + Pending int `json:"pending,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExecutionID string `json:"execution_id,omitempty"` + Environment json.RawMessage `json:"environment,omitempty"` +} + +// ListReportsResponse is returned by Reports.List. +type ListReportsResponse struct { + Reports []Report `json:"reports"` + Total int `json:"total"` +} + +// DeleteReportResponse is returned by Reports.Delete. +type DeleteReportResponse struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` +} + +// UploadReportResponse is returned by Reports.Upload. +type UploadReportResponse struct { + ID string `json:"id"` + Message string `json:"message"` + Tool string `json:"tool"` + Tests int `json:"tests"` + Results int `json:"results"` + ExecutionID string `json:"execution_id,omitempty"` + TriageGitHubStatus bool `json:"triage_github_status,omitempty"` + QualityGate *UploadQualityGateBlock `json:"qualityGate,omitempty"` +} + +// UploadQualityGateBlock is the quality gate section of an upload response. +type UploadQualityGateBlock struct { + Passed bool `json:"passed"` + Gates []UploadQualityGateEntry `json:"gates"` +} + +// UploadQualityGateEntry is one gate's evaluation in the upload response. +type UploadQualityGateEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Passed bool `json:"passed"` + Rules []QualityGateRuleResult `json:"rules"` +} + +// CompareReport is one side of a report comparison. +type CompareReport struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + ToolName string `json:"tool_name,omitempty"` + ToolVersion string `json:"tool_version,omitempty"` + Summary json.RawMessage `json:"summary"` + CreatedAt time.Time `json:"created_at"` + ExecutionID string `json:"execution_id,omitempty"` + Environment json.RawMessage `json:"environment,omitempty"` +} + +// ReportTestDiff is a single per-test diff entry from CompareReports. +type ReportTestDiff struct { + Name string `json:"name"` + Suite string `json:"suite,omitempty"` + FilePath string `json:"file_path,omitempty"` + BaseStatus string `json:"base_status,omitempty"` + HeadStatus string `json:"head_status,omitempty"` + BaseDurationMs int64 `json:"base_duration_ms,omitempty"` + HeadDurationMs int64 `json:"head_duration_ms,omitempty"` + DurationDeltaMs int64 `json:"duration_delta_ms,omitempty"` + DurationDeltaPct float64 `json:"duration_delta_pct,omitempty"` + Message string `json:"message,omitempty"` +} + +// ReportDiffSummary is the aggregate summary of a comparison. +type ReportDiffSummary struct { + BaseTests int `json:"base_tests"` + HeadTests int `json:"head_tests"` + NewFailures int `json:"new_failures"` + Fixed int `json:"fixed"` + DurationRegressions int `json:"duration_regressions"` +} + +// ReportDiff is the diff block of a comparison response. +type ReportDiff struct { + NewFailures []ReportTestDiff `json:"new_failures"` + Fixed []ReportTestDiff `json:"fixed"` + DurationRegressions []ReportTestDiff `json:"duration_regressions"` + Summary ReportDiffSummary `json:"summary"` +} + +// ReportCompareResult is returned by Reports.Compare. +type ReportCompareResult struct { + Base CompareReport `json:"base"` + Head CompareReport `json:"head"` + Diff ReportDiff `json:"diff"` +} + +// TriageFailureEntry is a single failure classified by triage. +type TriageFailureEntry struct { + TestResultID string `json:"test_result_id"` + Classification string `json:"classification"` +} + +// TriageCluster is a group of failures sharing a root cause. +type TriageCluster struct { + ID string `json:"id"` + RootCause string `json:"root_cause"` + Failures []TriageFailureEntry `json:"failures"` + Label string `json:"label,omitempty"` +} + +// ReportTriageMetadata is the metadata block of a triage result. +type ReportTriageMetadata struct { + GeneratedAt time.Time `json:"generated_at"` + Model string `json:"model,omitempty"` +} + +// ReportTriageResult is returned by Reports.GetTriage. +type ReportTriageResult struct { + TriageStatus string `json:"triage_status"` + Clusters []TriageCluster `json:"clusters,omitempty"` + UnclusteredFailures []TriageFailureEntry `json:"unclustered_failures,omitempty"` + Summary string `json:"summary,omitempty"` + Error string `json:"error,omitempty"` + Metadata *ReportTriageMetadata `json:"metadata,omitempty"` +} + +// RetryTriageResponse is returned by Reports.RetryTriage. +type RetryTriageResponse struct { + TriageStatus string `json:"triage_status"` +} + +// ── Executions ─────────────────────────────────────────────────────────────── + +// ExecutionStatus is the lifecycle state of a test execution. +type ExecutionStatus string + +const ( + ExecutionStatusPending ExecutionStatus = "pending" + ExecutionStatusRunning ExecutionStatus = "running" + ExecutionStatusCompleted ExecutionStatus = "completed" + ExecutionStatusFailed ExecutionStatus = "failed" + ExecutionStatusCancelled ExecutionStatus = "cancelled" +) + +// UpdateExecutionStatus is the subset of statuses accepted by UpdateStatus. +type UpdateExecutionStatus string + +const ( + UpdateExecutionRunning UpdateExecutionStatus = "running" + UpdateExecutionCompleted UpdateExecutionStatus = "completed" + UpdateExecutionFailed UpdateExecutionStatus = "failed" + UpdateExecutionCancelled UpdateExecutionStatus = "cancelled" +) + +// TestResultStatus is the status of a single test result. +type TestResultStatus string + +const ( + TestResultPassed TestResultStatus = "passed" + TestResultFailed TestResultStatus = "failed" + TestResultSkipped TestResultStatus = "skipped" + TestResultPending TestResultStatus = "pending" + TestResultOther TestResultStatus = "other" +) + +// WorkerStatus is the lifecycle state of a sharded worker. +type WorkerStatus string + +const ( + WorkerStatusStarting WorkerStatus = "starting" + WorkerStatusRunning WorkerStatus = "running" + WorkerStatusIdle WorkerStatus = "idle" + WorkerStatusCompleted WorkerStatus = "completed" + WorkerStatusFailed WorkerStatus = "failed" +) + +// Execution is the API representation of a test execution. +type Execution struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Command string `json:"command"` + Status ExecutionStatus `json:"status"` + Config json.RawMessage `json:"config,omitempty"` + ReportID string `json:"report_id,omitempty"` + K8sJobName string `json:"k8s_job_name,omitempty"` + K8sPodName string `json:"k8s_pod_name,omitempty"` + ErrorMsg string `json:"error_msg,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + +// ListExecutionsResponse is returned by Executions.List. +type ListExecutionsResponse struct { + Executions []Execution `json:"executions"` + Total int `json:"total"` +} + +// CreateExecutionResponse is returned by Executions.Create. +type CreateExecutionResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Command string `json:"command"` +} + +// CancelExecutionResponse is returned by Executions.Cancel. +type CancelExecutionResponse struct { + ID string `json:"id"` + Status string `json:"status"` +} + +// UpdateExecutionStatusResponse is returned by Executions.UpdateStatus. +type UpdateExecutionStatusResponse struct { + ID string `json:"id"` + Status string `json:"status"` +} + +// ExecutionProgress is the body for ReportProgress. +type ExecutionProgress struct { + Passed int `json:"passed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Total int `json:"total"` + DurationMs int64 `json:"duration_ms,omitempty"` + EstimatedETASeconds float64 `json:"estimated_eta_seconds,omitempty"` +} + +// ExecutionProgressResponse is returned by ReportProgress. +type ExecutionProgressResponse struct { + ExecutionID string `json:"execution_id"` + Received bool `json:"received"` +} + +// TestResultEvent is the body for ReportTestResult. +type TestResultEvent struct { + Name string `json:"name"` + Status TestResultStatus `json:"status"` + DurationMs int64 `json:"duration_ms,omitempty"` + Message string `json:"message,omitempty"` + Suite string `json:"suite,omitempty"` + WorkerID string `json:"worker_id,omitempty"` +} + +// WorkerStatusEvent is the body for ReportWorkerStatus. +type WorkerStatusEvent struct { + WorkerID string `json:"worker_id"` + Status WorkerStatus `json:"status"` + Message string `json:"message,omitempty"` + TestsAssigned int `json:"tests_assigned,omitempty"` + TestsCompleted int `json:"tests_completed,omitempty"` +} + +// ExecutionReceivedResponse is returned by ReportTestResult and ReportWorkerStatus. +type ExecutionReceivedResponse struct { + ExecutionID string `json:"execution_id"` + Received bool `json:"received"` +} + +// ── Analytics ──────────────────────────────────────────────────────────────── + +// TrendPoint is a single point in a pass/fail trend. +type TrendPoint struct { + Date string `json:"date"` + PassRate float64 `json:"pass_rate"` + Total int `json:"total"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` +} + +// TrendsResponse is returned by Analytics.GetTrends. +type TrendsResponse struct { + Trends []TrendPoint `json:"trends"` +} + +// FlakyTest is a test detected as flaky. +type FlakyTest struct { + Name string `json:"name"` + Suite string `json:"suite,omitempty"` + FilePath string `json:"file_path,omitempty"` + FlipCount int `json:"flip_count"` + TotalRuns int `json:"total_runs"` + FlipRate float64 `json:"flip_rate"` + LastStatus string `json:"last_status"` +} + +// FlakyTestsResponse is returned by Analytics.GetFlakyTests. +type FlakyTestsResponse struct { + FlakyTests []FlakyTest `json:"flaky_tests"` +} + +// ErrorCluster groups similar error messages. +type ErrorCluster struct { + Message string `json:"message"` + Count int `json:"count"` + TestNames []string `json:"test_names"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` +} + +// ErrorAnalysisResponse is returned by Analytics.GetErrorAnalysis. +type ErrorAnalysisResponse struct { + Errors []ErrorCluster `json:"errors"` +} + +// DurationBucket is a histogram bucket. +type DurationBucket struct { + Range string `json:"range"` + MinMs int64 `json:"min_ms"` + MaxMs int64 `json:"max_ms"` + Count int `json:"count"` +} + +// DurationDistributionResponse is returned by Analytics.GetDurationDistribution. +type DurationDistributionResponse struct { + Distribution []DurationBucket `json:"distribution"` +} + +// ── Quality Gates ──────────────────────────────────────────────────────────── + +// QualityGateRule is a single rule in a quality gate. Params is an opaque +// JSON object whose shape depends on the rule type. +type QualityGateRule struct { + Type string `json:"type"` + Params json.RawMessage `json:"params"` +} + +// QualityGate is a configured quality gate. +type QualityGate struct { + ID string `json:"id"` + Name string `json:"name"` + TeamID string `json:"team_id"` + Description string `json:"description,omitempty"` + Rules json.RawMessage `json:"rules"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListQualityGatesResponse is returned by QualityGates.List. +type ListQualityGatesResponse struct { + QualityGates []QualityGate `json:"quality_gates"` + Total int `json:"total"` +} + +// QualityGateRuleResult is a single rule evaluation result. +type QualityGateRuleResult struct { + Metric string `json:"metric"` + Threshold interface{} `json:"threshold"` + Actual interface{} `json:"actual"` + Passed bool `json:"passed"` + Message string `json:"message"` +} + +// QualityGateEvalRuleResult is a single rule result in an evaluation record. +type QualityGateEvalRuleResult struct { + Type string `json:"type"` + Passed bool `json:"passed"` + Threshold interface{} `json:"threshold"` + Actual interface{} `json:"actual"` + Message string `json:"message"` +} + +// EvaluateQualityGateResponse is returned by QualityGates.Evaluate. +type EvaluateQualityGateResponse struct { + ID string `json:"id"` + GateID string `json:"gate_id"` + ReportID string `json:"report_id"` + Passed bool `json:"passed"` + Rules []QualityGateRuleResult `json:"rules"` +} + +// QualityGateEvaluation is a persisted evaluation record. +type QualityGateEvaluation struct { + ID string `json:"id"` + GateID string `json:"gate_id"` + ReportID string `json:"report_id"` + Passed bool `json:"passed"` + Details json.RawMessage `json:"details"` + CreatedAt time.Time `json:"created_at"` +} + +// ListEvaluationsResponse is returned by QualityGates.ListEvaluations. +type ListEvaluationsResponse struct { + Evaluations []QualityGateEvaluation `json:"evaluations"` + Total int `json:"total"` +} + +// DeleteQualityGateResponse is returned by QualityGates.Delete. +type DeleteQualityGateResponse struct { + Message string `json:"message"` +} + +// ── Teams ──────────────────────────────────────────────────────────────────── + +// Team is a team record. +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} + +// TeamWithRole is a team paired with the caller's role. +type TeamWithRole struct { + Team + Role string `json:"role"` +} + +// ListTeamsResponse is returned by Teams.List. +type ListTeamsResponse struct { + Teams []TeamWithRole `json:"teams"` +} + +// GetTeamResponse is returned by Teams.Get. +type GetTeamResponse struct { + Team Team `json:"team"` + Role string `json:"role"` +} + +// DeleteTeamResponse is returned by Teams.Delete. +type DeleteTeamResponse struct { + Message string `json:"message"` +} + +// TeamToken is a (redacted) API token record. +type TeamToken struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ListTokensResponse is returned by Teams.ListTokens. +type ListTokensResponse struct { + Tokens []TeamToken `json:"tokens"` +} + +// CreateTokenResponse is returned by Teams.CreateToken. The Token field +// contains the full token value and is only returned once at creation time. +type CreateTokenResponse struct { + Token string `json:"token"` + ID string `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + CreatedAt time.Time `json:"created_at"` +} + +// DeleteTokenResponse is returned by Teams.DeleteToken. +type DeleteTokenResponse struct { + Message string `json:"message"` +} + +// ── Webhooks ───────────────────────────────────────────────────────────────── + +// WebhookEventType is a supported webhook event. +type WebhookEventType string + +const ( + WebhookEventReportSubmitted WebhookEventType = "report.submitted" + WebhookEventGateFailed WebhookEventType = "gate.failed" + WebhookEventExecutionCompleted WebhookEventType = "execution.completed" + WebhookEventExecutionFailed WebhookEventType = "execution.failed" + WebhookEventRunTriageComplete WebhookEventType = "run.triage_complete" +) + +// Webhook is a webhook subscription. +type Webhook struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + URL string `json:"url"` + Events []WebhookEventType `json:"events"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListWebhooksResponse is returned by Teams.ListWebhooks. +type ListWebhooksResponse struct { + Webhooks []Webhook `json:"webhooks"` + Total int `json:"total"` +} + +// CreateWebhookResponse is returned by Teams.CreateWebhook. The Secret is +// only returned once at creation time. +type CreateWebhookResponse struct { + Webhook Webhook `json:"webhook"` + Secret string `json:"secret"` +} + +// DeleteWebhookResponse is returned by Teams.DeleteWebhook. +type DeleteWebhookResponse struct { + Message string `json:"message"` +} + +// WebhookDelivery is a single delivery attempt record. +type WebhookDelivery struct { + ID string `json:"id"` + WebhookID string `json:"webhook_id"` + URL string `json:"url"` + EventType string `json:"event_type"` + Attempt int `json:"attempt"` + StatusCode int `json:"status_code"` + DurationMs int `json:"duration_ms"` + Error string `json:"error,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + DeliveredAt time.Time `json:"delivered_at"` +} + +// ListWebhookDeliveriesResponse is returned by Teams.ListWebhookDeliveries. +type ListWebhookDeliveriesResponse struct { + Deliveries []WebhookDelivery `json:"deliveries"` + Total int `json:"total"` +} + +// RetryWebhookDeliveryResponse is returned by Teams.RetryWebhookDelivery. +type RetryWebhookDeliveryResponse struct { + Success bool `json:"success"` + StatusCode int `json:"status_code"` + Attempt int `json:"attempt"` + DurationMs int `json:"duration_ms"` + Error string `json:"error"` +} + +// ── Invitations ────────────────────────────────────────────────────────────── + +// Invitation is a pending or accepted team invitation. +type Invitation struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Email string `json:"email"` + Role string `json:"role"` + InvitedBy string `json:"invited_by,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + AcceptedAt *time.Time `json:"accepted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ListInvitationsResponse is returned by Teams.ListInvitations. +type ListInvitationsResponse struct { + Invitations []Invitation `json:"invitations"` +} + +// CreateInvitationResponse is returned by Teams.CreateInvitation. The Token +// is only returned once at creation time. +type CreateInvitationResponse struct { + Invitation Invitation `json:"invitation"` + Token string `json:"token"` +} + +// InvitationPreview is returned by Teams.PreviewInvitation. +type InvitationPreview struct { + Email string `json:"email"` + Role string `json:"role"` + TeamName string `json:"team_name"` + ExpiresAt time.Time `json:"expires_at"` +} + +// AcceptInvitationResponse is returned by Teams.AcceptInvitation. +type AcceptInvitationResponse struct { + Message string `json:"message"` + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + Role string `json:"role"` +} + +// RevokeInvitationResponse is returned by Teams.RevokeInvitation. +type RevokeInvitationResponse struct { + Message string `json:"message"` +} + +// ── Sharding ───────────────────────────────────────────────────────────────── + +// Shard is one worker's assigned tests in a shard plan. +type Shard struct { + WorkerID string `json:"worker_id"` + TestNames []string `json:"test_names"` + EstDurationMs int64 `json:"est_duration_ms"` + TestCount int `json:"test_count"` +} + +// ShardPlan is the complete distribution plan for a sharded execution. +type ShardPlan struct { + ExecutionID string `json:"execution_id"` + TotalWorkers int `json:"total_workers"` + Strategy string `json:"strategy"` + Shards []Shard `json:"shards"` + EstTotalMs int64 `json:"est_total_ms"` + EstWallClockMs int64 `json:"est_wall_clock_ms"` +} + +// CreateShardPlanRequest is the body for Sharding.CreatePlan. +type CreateShardPlanRequest struct { + TestNames []string `json:"test_names"` + NumWorkers int `json:"num_workers"` + Strategy string `json:"strategy,omitempty"` + ExecutionID string `json:"execution_id,omitempty"` + Dependencies map[string][]string `json:"dependencies,omitempty"` +} + +// RebalanceShardsRequest is the body for Sharding.Rebalance. +type RebalanceShardsRequest struct { + ExecutionID string `json:"execution_id"` + FailedWorkerID string `json:"failed_worker_id"` + CurrentPlan ShardPlan `json:"current_plan"` + CompletedTests []string `json:"completed_tests,omitempty"` +} + +// TestDurationHistory is historical duration data for a single test. +type TestDurationHistory struct { + ID string `json:"id"` + TestName string `json:"test_name"` + Suite string `json:"suite"` + TeamID string `json:"team_id"` + AvgDurationMs int64 `json:"avg_duration_ms"` + MinDurationMs int64 `json:"min_duration_ms"` + MaxDurationMs int64 `json:"max_duration_ms"` + P95DurationMs int64 `json:"p95_duration_ms"` + RunCount int `json:"run_count"` + LastStatus string `json:"last_status"` + UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` +} + +// ListShardDurationsResponse is returned by Sharding.ListDurations. +type ListShardDurationsResponse struct { + Durations []TestDurationHistory `json:"durations"` + Total int `json:"total"` +} + +// ── Auth ───────────────────────────────────────────────────────────────────── + +// UserProfile is the authenticated user's profile. +type UserProfile struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` +} + +// AuthResponse is returned by Register and Login. +type AuthResponse struct { + User UserProfile `json:"user"` + AccessToken string `json:"access_token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// RegisterRequest is the body for Auth.Register. +type RegisterRequest struct { + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"display_name"` +} + +// LoginRequest is the body for Auth.Login. +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// RefreshTokenResponse is returned by Auth.Refresh. The ScaledTest API uses +// an HttpOnly refresh_token cookie for refresh; this struct captures only the +// JSON body. Callers using Refresh should provide the refresh token via +// WithRefreshToken on the request or by setting a cookie jar on the client. +type RefreshTokenResponse struct { + User UserProfile `json:"user"` + AccessToken string `json:"access_token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ChangePasswordRequest is the body for Auth.ChangePassword. +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +// ChangePasswordResponse is returned by Auth.ChangePassword. +type ChangePasswordResponse struct { + Message string `json:"message"` +} + +// UpdateProfileRequest is the body for Auth.UpdateProfile. +type UpdateProfileRequest struct { + DisplayName string `json:"display_name"` +} + +// ── Admin ──────────────────────────────────────────────────────────────────── + +// AdminUser is a user record returned by admin endpoints. +type AdminUser struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListUsersResponse is returned by Admin.ListUsers. +type ListUsersResponse struct { + Users []AdminUser `json:"users"` + Total int `json:"total"` +} + +// AuditLog is a single audit log entry. +type AuditLog struct { + ID string `json:"id"` + ActorID string `json:"actor_id"` + ActorEmail string `json:"actor_email"` + TeamID string `json:"team_id,omitempty"` + TeamName string `json:"team_name,omitempty"` + Action string `json:"action"` + ResourceType string `json:"resource_type,omitempty"` + ResourceID string `json:"resource_id,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ListAuditLogResponse is returned by Admin.ListAuditLog. +type ListAuditLogResponse struct { + AuditLog []AuditLog `json:"audit_log"` + Total int `json:"total"` +} + +// ── Health ─────────────────────────────────────────────────────────────────── + +// HealthResponse is returned by Health.Check. +type HealthResponse struct { + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` +}