Files
kandev/apps/backend/internal/github/pat_client_test.go
T

323 lines
9.0 KiB
Go

package github
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
func TestConvertPatPR(t *testing.T) {
raw := &patPR{
Number: 10,
Title: "Feature Y",
HTMLURL: "https://github.com/org/repo/pull/10",
State: "open",
Draft: false,
Additions: 200,
Deletions: 30,
CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2025, 3, 2, 0, 0, 0, 0, time.UTC),
RequestedReviewers: []struct {
Login string `json:"login"`
}{
{Login: "alice-reviewer"},
},
RequestedTeams: []struct {
Slug string `json:"slug"`
Name string `json:"name"`
}{
{Slug: "platform-team"},
},
User: struct {
Login string `json:"login"`
}{Login: "bob"},
Head: struct {
Ref string `json:"ref"`
SHA string `json:"sha"`
}{Ref: "feature-y", SHA: "deadbeef1234"},
Base: struct {
Ref string `json:"ref"`
}{Ref: "main"},
}
pr := convertPatPR(raw, "org", "repo")
if pr.Number != 10 {
t.Errorf("number = %d, want 10", pr.Number)
}
if pr.State != "open" {
t.Errorf("state = %q, want open", pr.State)
}
if pr.AuthorLogin != "bob" {
t.Errorf("author = %q, want bob", pr.AuthorLogin)
}
if pr.HeadBranch != "feature-y" {
t.Errorf("head = %q, want feature-y", pr.HeadBranch)
}
if pr.HeadSHA != "deadbeef1234" {
t.Errorf("head_sha = %q, want deadbeef1234", pr.HeadSHA)
}
if pr.Mergeable {
t.Error("expected mergeable = false when nil")
}
if len(pr.RequestedReviewers) != 2 {
t.Fatalf("requested reviewers = %d, want 2", len(pr.RequestedReviewers))
}
if pr.RequestedReviewers[0] != (RequestedReviewer{Login: "alice-reviewer", Type: reviewerTypeUser}) {
t.Errorf("unexpected first requested reviewer: %#v", pr.RequestedReviewers[0])
}
if pr.RequestedReviewers[1] != (RequestedReviewer{Login: "platform-team", Type: reviewerTypeTeam}) {
t.Errorf("unexpected second requested reviewer: %#v", pr.RequestedReviewers[1])
}
if pr.MergedAt != nil {
t.Error("expected nil MergedAt")
}
}
func TestConvertPatPR_Merged(t *testing.T) {
mergedAt := "2025-03-05T10:00:00Z"
raw := &patPR{
Number: 5,
State: "closed",
MergedAt: &mergedAt,
User: struct {
Login string `json:"login"`
}{Login: "alice"},
Head: struct {
Ref string `json:"ref"`
SHA string `json:"sha"`
}{Ref: "fix"},
Base: struct {
Ref string `json:"ref"`
}{Ref: "main"},
}
pr := convertPatPR(raw, "org", "repo")
if pr.State != prStateMerged {
t.Errorf("state = %q, want merged", pr.State)
}
if pr.MergedAt == nil {
t.Fatal("expected non-nil MergedAt")
}
}
func TestConvertPatPR_Mergeable(t *testing.T) {
mergeable := true
raw := &patPR{
Number: 1,
State: "open",
Mergeable: &mergeable,
User: struct {
Login string `json:"login"`
}{Login: "alice"},
Head: struct {
Ref string `json:"ref"`
SHA string `json:"sha"`
}{Ref: "b"},
Base: struct {
Ref string `json:"ref"`
}{Ref: "main"},
}
pr := convertPatPR(raw, "o", "r")
if !pr.Mergeable {
t.Error("expected mergeable = true")
}
}
func TestConvertPatPR_MergeableState(t *testing.T) {
raw := &patPR{
Number: 2,
State: "open",
MergeableState: "CLEAN", // GitHub REST uses lowercase but be defensive
User: struct {
Login string `json:"login"`
}{Login: "alice"},
Head: struct {
Ref string `json:"ref"`
SHA string `json:"sha"`
}{Ref: "b"},
Base: struct {
Ref string `json:"ref"`
}{Ref: "main"},
}
pr := convertPatPR(raw, "o", "r")
if pr.MergeableState != "clean" {
t.Errorf("expected normalized mergeable_state=clean, got %q", pr.MergeableState)
}
}
func TestConvertPatRequestedReviewers(t *testing.T) {
raw := &patPR{
RequestedReviewers: []struct {
Login string `json:"login"`
}{
{Login: "alice"},
{},
},
RequestedTeams: []struct {
Slug string `json:"slug"`
Name string `json:"name"`
}{
{Slug: "my-team"},
{Name: "fallback-team"},
{},
},
}
got := convertPatRequestedReviewers(raw)
if len(got) != 3 {
t.Fatalf("requested reviewers = %d, want 3", len(got))
}
if got[0] != (RequestedReviewer{Login: "alice", Type: reviewerTypeUser}) {
t.Errorf("unexpected first reviewer: %#v", got[0])
}
if got[1] != (RequestedReviewer{Login: "my-team", Type: reviewerTypeTeam}) {
t.Errorf("unexpected second reviewer: %#v", got[1])
}
if got[2] != (RequestedReviewer{Login: "fallback-team", Type: reviewerTypeTeam}) {
t.Errorf("unexpected third reviewer: %#v", got[2])
}
}
func TestPATClient_RecordsRateHeadersOnSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-RateLimit-Limit", "5000")
w.Header().Set("X-RateLimit-Remaining", "4998")
w.Header().Set("X-RateLimit-Reset", "2000000000")
w.Header().Set("X-RateLimit-Resource", "core")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"login":"octocat"}`))
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
tracker := NewRateTracker(nil, nil)
c.WithRateTracker(tracker)
var out struct {
Login string `json:"login"`
}
if err := c.get(context.Background(), "/user", &out); err != nil {
t.Fatalf("get: %v", err)
}
snap, ok := tracker.Snapshot(ResourceCore)
if !ok {
t.Fatalf("expected core snapshot")
}
if snap.Remaining != 4998 || snap.Limit != 5000 {
t.Fatalf("snap = %+v", snap)
}
}
// Regression: when a 429 carries valid X-RateLimit-Reset headers, the reset
// time from the headers must win — not the synthetic +1h fallback in
// markRateExhausted. Previously, recordRateHeaders called Record(snap)
// followed by markRateExhausted(time.Time{}), and the second call clobbered
// the real reset with a 1-hour pause that could over-throttle the poller.
func TestPATClient_RateLimit429_PreservesRealReset(t *testing.T) {
realReset := time.Now().Add(7 * time.Minute).UTC().Truncate(time.Second)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-RateLimit-Limit", "5000")
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(realReset.Unix(), 10))
w.Header().Set("X-RateLimit-Resource", "core")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"message":"API rate limit exceeded"}`))
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
tracker := NewRateTracker(nil, nil)
c.WithRateTracker(tracker)
var out struct{}
if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil {
t.Fatalf("expected error from 429")
}
snap, ok := tracker.Snapshot(ResourceCore)
if !ok {
t.Fatalf("expected core snapshot")
}
if !snap.ResetAt.Equal(realReset) {
t.Errorf("expected reset_at preserved from headers (%s), got %s (off by %s)",
realReset, snap.ResetAt, snap.ResetAt.Sub(realReset))
}
if !tracker.IsExhausted(ResourceCore) {
t.Errorf("expected core to be exhausted")
}
}
// When a 429 has no rate-limit headers, the synthetic 1h fallback still
// applies so the poller pauses instead of hammering the secondary limit.
func TestPATClient_RateLimit429_NoHeaders_UsesSyntheticReset(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"message":"abuse detection"}`))
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
tracker := NewRateTracker(nil, nil)
c.WithRateTracker(tracker)
var out struct{}
if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil {
t.Fatalf("expected error from 429")
}
if !tracker.IsExhausted(ResourceCore) {
t.Fatalf("expected core exhausted via synthetic fallback")
}
}
func TestPATClient_MarksExhaustedFromRateLimitBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// No headers — secondary limits sometimes omit them entirely.
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"message":"API rate limit exceeded for user."}`))
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
tracker := NewRateTracker(nil, nil)
c.WithRateTracker(tracker)
var out struct{}
if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil {
t.Fatalf("expected error from 403")
}
if !tracker.IsExhausted(ResourceCore) {
t.Fatalf("expected core exhausted from body parse")
}
}
// newPATClientPointingAt builds a PATClient whose underlying HTTP client
// reroutes any github API URL to the given test server.
func newPATClientPointingAt(t *testing.T, baseURL string) *PATClient {
t.Helper()
c := NewPATClient("test-token")
c.httpClient = &http.Client{
Transport: &rewriteTransport{base: baseURL},
Timeout: 2 * time.Second,
}
return c
}
type rewriteTransport struct{ base string }
func (rt *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
rewritten := strings.Replace(req.URL.String(), githubAPIBase, rt.base, 1)
req2, err := http.NewRequestWithContext(req.Context(), req.Method, rewritten, req.Body)
if err != nil {
return nil, err
}
req2.Header = req.Header.Clone()
return http.DefaultTransport.RoundTrip(req2)
}