gitcrawl/internal/github/client_test.go
2026-05-05 03:26:39 +01:00

365 lines
13 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestListRepositoryIssuesPaginatesAndLimits(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer token" {
t.Fatalf("missing auth header: %q", r.Header.Get("Authorization"))
}
switch r.URL.Query().Get("page") {
case "":
w.Header().Set("Link", `<`+serverURL(r)+`?page=2>; rel="next", <`+serverURL(r)+`?page=2>; rel="last"`)
_ = json.NewEncoder(w).Encode([]map[string]any{{"number": 1}, {"number": 2}})
case "2":
_ = json.NewEncoder(w).Encode([]map[string]any{{"number": 3}})
default:
t.Fatalf("unexpected page: %s", r.URL.RawQuery)
}
}))
defer server.Close()
var messages []string
reporter := Reporter(func(message string) { messages = append(messages, message) })
client := New(Options{Token: "token", BaseURL: server.URL, PageDelay: -1})
rows, err := client.ListRepositoryIssues(context.Background(), "openclaw", "gitcrawl", ListIssuesOptions{Limit: 3}, reporter)
if err != nil {
t.Fatalf("list issues: %v", err)
}
if len(rows) != 3 {
t.Fatalf("rows: got %d want 3", len(rows))
}
if intValue(rows[2]["number"]) != 3 {
t.Fatalf("last number: %#v", rows[2]["number"])
}
joined := strings.Join(messages, "\n")
if !strings.Contains(joined, "page 1/2 fetched") || !strings.Contains(joined, "page 2/2 fetched") {
t.Fatalf("expected page X/Y log lines, got:\n%s", joined)
}
}
func TestListRepositoryIssuesUsesExpectedTotalWhenNoLastLink(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("page") {
case "":
// Cursor-style pagination: only "next", no "last".
w.Header().Set("Link", `<`+serverURL(r)+`?page=2>; rel="next"`)
rows := make([]map[string]any, 100)
for i := range rows {
rows[i] = map[string]any{"number": i + 1}
}
_ = json.NewEncoder(w).Encode(rows)
case "2":
rows := make([]map[string]any, 50)
for i := range rows {
rows[i] = map[string]any{"number": 100 + i + 1}
}
_ = json.NewEncoder(w).Encode(rows)
default:
t.Fatalf("unexpected page: %s", r.URL.RawQuery)
}
}))
defer server.Close()
var messages []string
reporter := Reporter(func(message string) { messages = append(messages, message) })
client := New(Options{BaseURL: server.URL, PageDelay: -1})
rows, err := client.ListRepositoryIssues(context.Background(), "openclaw", "gitcrawl", ListIssuesOptions{ExpectedTotal: 150}, reporter)
if err != nil {
t.Fatalf("list issues: %v", err)
}
if len(rows) != 150 {
t.Fatalf("rows: got %d want 150", len(rows))
}
joined := strings.Join(messages, "\n")
if !strings.Contains(joined, "page 1/2 fetched") || !strings.Contains(joined, "page 2/2 fetched") {
t.Fatalf("expected page X/Y log lines from hint, got:\n%s", joined)
}
}
func TestPaginateRaisesTotalWhenActualExceedsHint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("page") {
case "":
w.Header().Set("Link", `<`+serverURL(r)+`?page=2>; rel="next"`)
_ = json.NewEncoder(w).Encode([]map[string]any{{"number": 1}})
case "2":
_ = json.NewEncoder(w).Encode([]map[string]any{{"number": 2}})
default:
t.Fatalf("unexpected page: %s", r.URL.RawQuery)
}
}))
defer server.Close()
var messages []string
reporter := Reporter(func(message string) { messages = append(messages, message) })
client := New(Options{BaseURL: server.URL, PageDelay: -1})
// Hint underestimates (1 page) but the API actually returns 2.
if _, err := client.ListRepositoryIssues(context.Background(), "o", "r", ListIssuesOptions{ExpectedTotal: 1}, reporter); err != nil {
t.Fatalf("list: %v", err)
}
joined := strings.Join(messages, "\n")
if !strings.Contains(joined, "page 2/2 fetched") {
t.Fatalf("expected total to be raised to actual page count, got:\n%s", joined)
}
}
func TestRequestErrorIncludesStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusUnauthorized)
}))
defer server.Close()
client := New(Options{BaseURL: server.URL, PageDelay: -1})
_, err := client.GetRepo(context.Background(), "openclaw", "gitcrawl", nil)
if err == nil {
t.Fatal("expected error")
}
requestErr, ok := err.(*RequestError)
if !ok {
t.Fatalf("error type: %T", err)
}
if requestErr.Status != http.StatusUnauthorized {
t.Fatalf("status: got %d want %d", requestErr.Status, http.StatusUnauthorized)
}
if !strings.Contains((&RequestError{Method: "GET", URL: "https://example.test", Status: 500}).Error(), "status 500") {
t.Fatal("request error without body missing status")
}
if !strings.Contains((&RequestError{Method: "POST", URL: "https://example.test", Status: 400, Body: "bad"}).Error(), "bad") {
t.Fatal("request error with body missing body")
}
}
func TestClientSingleResourceAndCollectionEndpoints(t *testing.T) {
requests := map[string]int{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests[r.URL.Path]++
if r.Header.Get("Accept") != "application/vnd.github+json" {
t.Fatalf("accept header = %q", r.Header.Get("Accept"))
}
switch r.URL.Path {
case "/repos/openclaw/gitcrawl":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1})
case "/repos/openclaw/gitcrawl/issues/7":
_ = json.NewEncoder(w).Encode(map[string]any{"number": 7})
case "/repos/openclaw/gitcrawl/pulls/8":
_ = json.NewEncoder(w).Encode(map[string]any{"number": 8})
case "/repos/openclaw/gitcrawl/issues/7/comments",
"/repos/openclaw/gitcrawl/pulls/8/reviews",
"/repos/openclaw/gitcrawl/pulls/8/comments",
"/repos/openclaw/gitcrawl/pulls/8/files",
"/repos/openclaw/gitcrawl/pulls/8/commits":
_ = json.NewEncoder(w).Encode([]map[string]any{{"id": 1}})
case "/repos/openclaw/gitcrawl/commits/abc/check-runs":
_ = json.NewEncoder(w).Encode(map[string]any{"check_runs": []map[string]any{{"name": "test"}}})
case "/repos/openclaw/gitcrawl/actions/runs":
_ = json.NewEncoder(w).Encode(map[string]any{"workflow_runs": []map[string]any{{"id": 99}}})
default:
t.Fatalf("unexpected path: %s", r.URL.String())
}
}))
defer server.Close()
client := New(Options{BaseURL: server.URL, PageDelay: -1})
ctx := context.Background()
if row, err := client.GetRepo(ctx, "openclaw", "gitcrawl", nil); err != nil || intValue(row["id"]) != 1 {
t.Fatalf("get repo = %#v %v", row, err)
}
if row, err := client.GetIssue(ctx, "openclaw", "gitcrawl", 7, nil); err != nil || intValue(row["number"]) != 7 {
t.Fatalf("get issue = %#v %v", row, err)
}
if row, err := client.GetPull(ctx, "openclaw", "gitcrawl", 8, nil); err != nil || intValue(row["number"]) != 8 {
t.Fatalf("get pull = %#v %v", row, err)
}
for name, fn := range map[string]func() ([]map[string]any, error){
"comments": func() ([]map[string]any, error) { return client.ListIssueComments(ctx, "openclaw", "gitcrawl", 7, nil) },
"reviews": func() ([]map[string]any, error) { return client.ListPullReviews(ctx, "openclaw", "gitcrawl", 8, nil) },
"review-comments": func() ([]map[string]any, error) {
return client.ListPullReviewComments(ctx, "openclaw", "gitcrawl", 8, nil)
},
"files": func() ([]map[string]any, error) { return client.ListPullFiles(ctx, "openclaw", "gitcrawl", 8, nil) },
"commits": func() ([]map[string]any, error) { return client.ListPullCommits(ctx, "openclaw", "gitcrawl", 8, nil) },
"checks": func() ([]map[string]any, error) {
return client.ListCommitCheckRuns(ctx, "openclaw", "gitcrawl", "abc", nil)
},
"runs": func() ([]map[string]any, error) {
return client.ListWorkflowRuns(ctx, "openclaw", "gitcrawl", ListWorkflowRunsOptions{HeadSHA: "abc"}, nil)
},
} {
rows, err := fn()
if err != nil || len(rows) != 1 {
t.Fatalf("%s rows = %+v err=%v", name, rows, err)
}
}
if len(requests) != 10 {
t.Fatalf("requests = %+v", requests)
}
}
func TestNextPageAndReporterBranches(t *testing.T) {
header := `<https://api.github.test/repos/o/r/issues?page=2&state=open>; rel="next", <https://api.github.test/repos/o/r/issues?page=9>; rel="last"`
if got := nextPage(header); got != "/repos/o/r/issues?page=2&state=open" {
t.Fatalf("next page = %q", got)
}
if got := nextPage(`<bad-url>; rel="last"`); got != "" {
t.Fatalf("bad next page = %q", got)
}
if got := lastPage(header); got != 9 {
t.Fatalf("last page = %d", got)
}
if got := lastPage(`<https://api.github.test/x?page=3>; rel="next"`); got != 0 {
t.Fatalf("last page without rel=last = %d", got)
}
if got := lastPage(`<%zz>; rel="last"`); got != 0 {
t.Fatalf("last page bad url = %d", got)
}
var messages []string
Reporter(func(message string) { messages = append(messages, message) }).Printf("hello %d", 1)
if len(messages) != 1 || messages[0] != "hello 1" {
t.Fatalf("messages = %+v", messages)
}
Reporter(nil).Printf("ignored")
}
func TestClientErrorAndHelperBranches(t *testing.T) {
client := New(Options{})
if client.baseURL != "https://api.github.com" || client.userAgent != "gitcrawl" {
t.Fatalf("defaults = %+v", client)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/repos/openclaw/gitcrawl":
_, _ = w.Write([]byte("{"))
case "/repos/openclaw/gitcrawl/issues":
_ = json.NewEncoder(w).Encode([]map[string]any{{"number": 1}, {"number": 2}, {"number": 3}})
default:
t.Fatalf("unexpected path: %s", r.URL.String())
}
}))
defer server.Close()
client = New(Options{BaseURL: server.URL, PageDelay: -1})
if _, err := client.GetRepo(context.Background(), "openclaw", "gitcrawl", nil); err == nil {
t.Fatal("bad json should fail")
}
rows, err := client.ListRepositoryIssues(context.Background(), "openclaw", "gitcrawl", ListIssuesOptions{State: "closed", Since: "2026-04-30T00:00:00Z", Limit: 2}, nil)
if err != nil {
t.Fatalf("limited issues: %v", err)
}
if len(rows) != 2 {
t.Fatalf("limited rows = %+v", rows)
}
if got := pathEscape("owner/name"); got != "owner%2Fname" {
t.Fatalf("escaped path = %q", got)
}
if got := intValue(json.Number("7")); got != 7 {
t.Fatalf("json int = %d", got)
}
if got := intValue("bad"); got != 0 {
t.Fatalf("bad int = %d", got)
}
}
func TestRateLimitRetriesOn403WithRemainingZero(t *testing.T) {
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&calls, 1) == 1 {
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", time.Now().Unix()))
http.Error(w, "rate limited", http.StatusForbidden)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1})
}))
defer server.Close()
client := New(Options{BaseURL: server.URL, PageDelay: -1})
row, err := client.GetRepo(context.Background(), "openclaw", "gitcrawl", nil)
if err != nil {
t.Fatalf("get repo: %v", err)
}
if intValue(row["id"]) != 1 {
t.Fatalf("row = %#v", row)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("calls = %d want 2", got)
}
}
func TestRateLimitRetriesOn429WithRetryAfter(t *testing.T) {
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&calls, 1) == 1 {
w.Header().Set("Retry-After", "1")
http.Error(w, "slow down", http.StatusTooManyRequests)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": 2})
}))
defer server.Close()
client := New(Options{BaseURL: server.URL, PageDelay: -1})
row, err := client.GetRepo(context.Background(), "openclaw", "gitcrawl", nil)
if err != nil {
t.Fatalf("get repo: %v", err)
}
if intValue(row["id"]) != 2 {
t.Fatalf("row = %#v", row)
}
}
func TestRateLimitRespectsContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", time.Now().Add(time.Hour).Unix()))
http.Error(w, "rate limited", http.StatusForbidden)
}))
defer server.Close()
client := New(Options{BaseURL: server.URL, PageDelay: -1})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
_, err := client.GetRepo(ctx, "openclaw", "gitcrawl", nil)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v", err)
}
}
func TestNonRateLimit403IsNotRetried(t *testing.T) {
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer server.Close()
client := New(Options{BaseURL: server.URL, PageDelay: -1})
if _, err := client.GetRepo(context.Background(), "openclaw", "gitcrawl", nil); err == nil {
t.Fatal("expected error")
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("calls = %d want 1", got)
}
}
func serverURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host + r.URL.Path
}