package github import ( "context" "net/http" "net/http/httptest" "testing" "time" ) func TestNewClient_DefaultBaseURL(t *testing.T) { c := NewClient("test-token", "") if c.baseURL != "https://api.github.com" { t.Errorf("expected default base URL, got %q", c.baseURL) } } func TestNewClient_CustomBaseURL(t *testing.T) { c := NewClient("test-token", "https://github.concur.com/api/v3") if c.baseURL != "https://github.concur.com/api/v3" { t.Errorf("expected custom base URL, got %q", c.baseURL) } } func TestNewClient_TrimsTrailingSlash(t *testing.T) { c := NewClient("test-token", "https://github.concur.com/api/v3/") if c.baseURL != "https://github.concur.com/api/v3" { t.Errorf("expected trailing slash trimmed, got %q", c.baseURL) } } func TestDoRequest_SetsAuthHeader(t *testing.T) { var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.WriteHeader(200) w.Write([]byte("{}")) })) defer srv.Close() c := NewClient("my-token", srv.URL) c.SetHTTPClient(srv.Client()) _, _ = c.doGet(context.Background(), srv.URL+"/test") if gotAuth != "Bearer my-token" { t.Errorf("expected Bearer auth, got %q", gotAuth) } } func TestDoRequest_SetsDefaultAcceptHeader(t *testing.T) { var gotAccept string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAccept = r.Header.Get("Accept") w.WriteHeader(200) w.Write([]byte("{}")) })) defer srv.Close() c := NewClient("token", srv.URL) c.SetHTTPClient(srv.Client()) _, _ = c.doGet(context.Background(), srv.URL+"/test") if gotAccept != "application/vnd.github+json" { t.Errorf("expected default Accept header, got %q", gotAccept) } } func TestDoRequest_429Retry(t *testing.T) { attempts := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ if attempts == 1 { w.WriteHeader(429) w.Write([]byte(`{"message":"rate limit"}`)) return } w.WriteHeader(200) w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() c := NewClient("token", srv.URL) c.SetHTTPClient(srv.Client()) c.SetRetryBackoff([]time.Duration{10 * time.Millisecond, 10 * time.Millisecond}) body, err := c.doGet(context.Background(), srv.URL+"/test") if err != nil { t.Fatalf("unexpected error: %v", err) } if string(body) != `{"ok":true}` { t.Errorf("unexpected body: %s", body) } if attempts != 2 { t.Errorf("expected 2 attempts, got %d", attempts) } } func TestDoRequest_429ExhaustsRetries(t *testing.T) { attempts := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ w.WriteHeader(429) w.Write([]byte(`{"message":"rate limit"}`)) })) defer srv.Close() c := NewClient("token", srv.URL) c.SetHTTPClient(srv.Client()) c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}) _, err := c.doGet(context.Background(), srv.URL+"/test") if err == nil { t.Fatal("expected error after exhausting retries") } apiErr, ok := err.(*APIError) if !ok { t.Fatalf("expected *APIError, got %T", err) } if apiErr.StatusCode != 429 { t.Errorf("expected 429, got %d", apiErr.StatusCode) } if attempts != 3 { t.Errorf("expected 3 attempts, got %d", attempts) } } func TestDoRequest_404NoRetry(t *testing.T) { attempts := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ w.WriteHeader(404) w.Write([]byte(`{"message":"not found"}`)) })) defer srv.Close() c := NewClient("token", srv.URL) c.SetHTTPClient(srv.Client()) _, err := c.doGet(context.Background(), srv.URL+"/test") if err == nil { t.Fatal("expected error for 404") } if attempts != 1 { t.Errorf("expected 1 attempt (no retry on 404), got %d", attempts) } } func TestDoRequest_401NoRetry(t *testing.T) { attempts := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ w.WriteHeader(401) w.Write([]byte(`{"message":"bad credentials"}`)) })) defer srv.Close() c := NewClient("token", srv.URL) c.SetHTTPClient(srv.Client()) _, err := c.doGet(context.Background(), srv.URL+"/test") if err == nil { t.Fatal("expected error for 401") } if attempts != 1 { t.Errorf("expected 1 attempt (no retry on 401), got %d", attempts) } } func TestIsNotFound(t *testing.T) { err := &APIError{StatusCode: 404, Body: "not found"} if !IsNotFound(err) { t.Error("expected IsNotFound to return true for 404") } err2 := &APIError{StatusCode: 500, Body: "server error"} if IsNotFound(err2) { t.Error("expected IsNotFound to return false for 500") } } func TestIsUnauthorized(t *testing.T) { err := &APIError{StatusCode: 401, Body: "bad credentials"} if !IsUnauthorized(err) { t.Error("expected IsUnauthorized to return true for 401") } } func TestDoRequest_429RetryAfterHeader(t *testing.T) { if testing.Short() { t.Skip("skipping slow retry test in short mode") } attempts := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ if attempts == 1 { w.Header().Set("Retry-After", "1") w.WriteHeader(429) w.Write([]byte(`{"message":"rate limit"}`)) return } w.WriteHeader(200) w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() c := NewClient("token", srv.URL) c.SetHTTPClient(srv.Client()) // Use short backoff; Retry-After should override c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}) start := time.Now() body, err := c.doGet(context.Background(), srv.URL+"/test") elapsed := time.Since(start) if err != nil { t.Fatalf("unexpected error: %v", err) } if string(body) != `{"ok":true}` { t.Errorf("unexpected body: %s", body) } if attempts != 2 { t.Errorf("expected 2 attempts, got %d", attempts) } // Retry-After: 1 means at least 1 second delay if elapsed < 900*time.Millisecond { t.Errorf("expected ~1s delay from Retry-After, got %v", elapsed) } } func TestDoRequest_RetryAfterDoesNotMutateBackoff(t *testing.T) { if testing.Short() { t.Skip("skipping slow retry test in short mode") } attempts := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ if attempts == 1 { w.Header().Set("Retry-After", "1") w.WriteHeader(429) w.Write([]byte(`{"message":"rate limit"}`)) return } w.WriteHeader(200) w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() c := NewClient("token", srv.URL) c.SetHTTPClient(srv.Client()) c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}) _, err := c.doGet(context.Background(), srv.URL+"/test") if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify the original retryBackoff slice was not mutated if c.retryBackoff[0] != 1*time.Millisecond { t.Errorf("retryBackoff[0] was mutated: got %v, want 1ms", c.retryBackoff[0]) } if c.retryBackoff[1] != 1*time.Millisecond { t.Errorf("retryBackoff[1] was mutated: got %v, want 1ms", c.retryBackoff[1]) } } func TestDoRequest_SetsUserAgentHeader(t *testing.T) { var gotUA string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotUA = r.Header.Get("User-Agent") w.WriteHeader(200) w.Write([]byte("{}")) })) defer srv.Close() c := NewClient("token", srv.URL) c.SetHTTPClient(srv.Client()) _, _ = c.doGet(context.Background(), srv.URL+"/test") if gotUA != "review-bot/1.0" { t.Errorf("expected User-Agent 'review-bot/1.0', got %q", gotUA) } } func TestDoRequest_LimitsResponseBody(t *testing.T) { // Verify that responses are read through a limit reader. // We can't easily test the 10 MiB limit without OOM risk, // but we verify the constant is set correctly. if maxResponseBytes != 10*1024*1024 { t.Errorf("expected maxResponseBytes = 10 MiB, got %d", maxResponseBytes) } } func TestDoRequest_SkipsAuthWhenTokenEmpty(t *testing.T) { var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") w.WriteHeader(200) w.Write([]byte("{}")) })) defer srv.Close() c := NewClient("", srv.URL) // empty token c.SetHTTPClient(srv.Client()) _, _ = c.doGet(context.Background(), srv.URL+"/test") if gotAuth != "" { t.Errorf("expected no Authorization header with empty token, got %q", gotAuth) } } func TestNewClient_CheckRedirectStripsAuthOnCrossHost(t *testing.T) { // Verify the CheckRedirect function is configured c := NewClient("secret-token", "https://api.github.com") if c.httpClient.CheckRedirect == nil { t.Fatal("expected CheckRedirect to be set") } }