Files
kandev/apps/backend/internal/github/pat_client_test.go
T
José Almeida 36b5c682a9 fix(github): detect fork PRs by branch (#1182)
* fix(github): detect fork PRs by branch

* fix(github): align fork branch detection paths

* fix(github): skip duplicate branch PR matches

* test(github): remove stale branch owner fixtures

* test(github): remove stale PAT branch owner fixture

* fix(github): surface branch decode errors
2026-05-31 11:59:15 +01:00

760 lines
23 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"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 TestPATClient_FindPRByBranch_UsesGraphQLHeadRefName(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/graphql" {
t.Errorf("unexpected path %q, want /graphql", r.URL.Path)
http.Error(w, "unexpected path", http.StatusInternalServerError)
return
}
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
var body struct {
Query string `json:"query"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request body: %v", err)
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if !strings.Contains(body.Query, `pullRequests(first: 2, states: OPEN, headRefName: "feature")`) {
t.Errorf("query should look up branch by headRefName, got: %s", body.Query)
}
if strings.Contains(body.Query, `head=acme:feature`) || strings.Contains(body.Query, `ref(qualifiedName:`) {
t.Errorf("query should not require a base-repo branch ref, got: %s", body.Query)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{
"data": {
"b0": {
"pullRequests": {
"nodes": [{
"number": 12,
"state": "OPEN", "title": "fork PR", "url": "https://x/12",
"isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN",
"headRefName": "feature", "baseRefName": "main", "headRefOid": "abc123",
"author": {"login":"alice"},
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
"reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0},
"commits": {"nodes": []}
}]
}
}
}
}`))
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
pr, err := c.FindPRByBranch(context.Background(), "acme", "widget", "feature")
if err != nil {
t.Fatalf("FindPRByBranch: %v", err)
}
if pr == nil || pr.Number != 12 || pr.HeadBranch != "feature" {
t.Fatalf("unexpected PR: %#v", pr)
}
}
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")
}
}
func TestPATClient_FetchBranchProtection(t *testing.T) {
cases := []struct {
name string
status int
body string
wantHasRule bool
wantRequired int
wantErr bool
}{
{
name: "200 with required reviews",
status: http.StatusOK,
body: `{"required_pull_request_reviews":{"required_approving_review_count":2}}`,
wantHasRule: true,
wantRequired: 2,
},
{
name: "200 with rule but no required reviews block",
status: http.StatusOK,
body: `{"required_pull_request_reviews":null}`,
wantHasRule: true,
},
{
name: "404 maps to no rule",
status: http.StatusNotFound,
body: `{"message":"Branch not protected"}`,
wantHasRule: false,
},
{
name: "403 (no admin scope) maps to no rule",
status: http.StatusForbidden,
body: `{"message":"Resource not accessible by integration"}`,
wantHasRule: false,
},
{
name: "500 propagates as error",
status: http.StatusInternalServerError,
body: `{"message":"server error"}`,
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
wantPath := "/repos/o/r/branches/main/protection"
if r.URL.Path != wantPath {
t.Fatalf("path = %q, want %q", r.URL.Path, wantPath)
}
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
bp, err := c.FetchBranchProtection(context.Background(), "o", "r", "main")
if tc.wantErr {
if err == nil {
t.Fatal("want error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if bp.HasRule != tc.wantHasRule {
t.Fatalf("HasRule = %v, want %v", bp.HasRule, tc.wantHasRule)
}
if bp.RequiredApprovingReviewCount != tc.wantRequired {
t.Fatalf("RequiredApprovingReviewCount = %d, want %d",
bp.RequiredApprovingReviewCount, tc.wantRequired)
}
})
}
}
func TestListUserRepos_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/user":
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"login":"octocat"}`))
case "/search/repositories":
q := r.URL.Query().Get("q")
if !strings.Contains(q, "user:octocat") {
t.Errorf("q = %q, want to contain user:octocat", q)
}
if !strings.Contains(q, "demo") {
t.Errorf("q = %q, want to contain demo", q)
}
if got := r.URL.Query().Get("per_page"); got != "50" {
t.Errorf("per_page = %q, want 50", got)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"items":[
{"full_name":"octocat/demo","owner":{"login":"octocat"},"name":"demo","private":false,"default_branch":"main","description":"Public demo"},
{"full_name":"octocat/demo-private","owner":{"login":"octocat"},"name":"demo-private","private":true,"default_branch":"trunk","description":null}
]}`))
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
repos, err := c.ListUserRepos(context.Background(), "demo", 50)
if err != nil {
t.Fatalf("ListUserRepos: %v", err)
}
if len(repos) != 2 {
t.Fatalf("repos = %d, want 2", len(repos))
}
if repos[0].FullName != "octocat/demo" || repos[0].Owner != "octocat" || repos[0].Name != "demo" || repos[0].Private {
t.Errorf("unexpected first repo: %#v", repos[0])
}
if repos[0].DefaultBranch != "main" {
t.Errorf("first repo default_branch = %q, want main", repos[0].DefaultBranch)
}
if repos[0].Description != "Public demo" {
t.Errorf("first repo description = %q, want Public demo", repos[0].Description)
}
if !repos[1].Private {
t.Errorf("expected second repo private")
}
if repos[1].DefaultBranch != "trunk" {
t.Errorf("second repo default_branch = %q, want trunk", repos[1].DefaultBranch)
}
// JSON `null` description must decode to an empty string (omitempty drops it on serialize).
if repos[1].Description != "" {
t.Errorf("second repo description = %q, want empty string", repos[1].Description)
}
}
func TestListUserRepos_EmptyQuery(t *testing.T) {
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/user":
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"login":"alice"}`))
case "/search/repositories":
gotQuery = r.URL.Query().Get("q")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"items":[]}`))
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
repos, err := c.ListUserRepos(context.Background(), "", 0)
if err != nil {
t.Fatalf("ListUserRepos: %v", err)
}
if len(repos) != 0 {
t.Fatalf("repos = %d, want 0", len(repos))
}
if gotQuery != "user:alice" {
t.Errorf("q = %q, want exactly %q", gotQuery, "user:alice")
}
}
func TestListUserRepos_LimitClamping(t *testing.T) {
cases := []struct {
name string
inLimit int
wantPerPage string
}{
{"zero defaults to 20", 0, "20"},
{"negative defaults to 20", -5, "20"},
{"in range passes through", 42, "42"},
{"exceeds cap clamps to 100", 500, "100"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var gotPerPage string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/user":
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"login":"alice"}`))
case "/search/repositories":
gotPerPage = r.URL.Query().Get("per_page")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"items":[]}`))
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
if _, err := c.ListUserRepos(context.Background(), "", tc.inLimit); err != nil {
t.Fatalf("ListUserRepos: %v", err)
}
if gotPerPage != tc.wantPerPage {
t.Errorf("per_page = %q, want %q", gotPerPage, tc.wantPerPage)
}
})
}
}
func TestListUserRepos_APIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/user":
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"login":"alice"}`))
case "/search/repositories":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
repos, err := c.ListUserRepos(context.Background(), "", 0)
if err == nil {
t.Fatal("expected error from 500, got nil")
}
if repos != nil {
t.Errorf("expected nil repos on error, got %v", repos)
}
}
func TestListUserRepos_AuthError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"bad creds"}`))
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
repos, err := c.ListUserRepos(context.Background(), "", 0)
if err == nil {
t.Fatal("expected error when /user returns 401")
}
if repos != nil {
t.Errorf("expected nil repos on auth error, got %v", repos)
}
}
func TestClampRepoSearchLimit(t *testing.T) {
cases := []struct {
name string
in int
want int
}{
{"zero", 0, 20},
{"negative", -1, 20},
{"small", 10, 10},
{"exact cap", 100, 100},
{"over cap", 1000, 100},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := clampRepoSearchLimit(tc.in); got != tc.want {
t.Errorf("clampRepoSearchLimit(%d) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
// 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)
}
func TestListAccessibleRepos_Success(t *testing.T) {
var gotAffiliation, gotSort, gotPerPage string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/repos" {
// t.Fatalf must not run in a handler goroutine (it's a data race);
// use Errorf+return and let the main goroutine fail the assertion.
t.Errorf("unexpected path %q", r.URL.Path)
http.Error(w, "unexpected path", http.StatusInternalServerError)
return
}
gotAffiliation = r.URL.Query().Get("affiliation")
gotSort = r.URL.Query().Get("sort")
gotPerPage = r.URL.Query().Get("per_page")
w.WriteHeader(http.StatusOK)
// Flat array (NOT a search wrapper with .items).
_, _ = w.Write([]byte(`[
{"full_name":"kdlbs/kandev","owner":{"login":"kdlbs"},"name":"kandev","private":false,"default_branch":"main","description":"the app","pushed_at":"2025-05-01T10:00:00Z"},
{"full_name":"alice/secret","owner":{"login":"alice"},"name":"secret","private":true,"default_branch":"trunk","description":null}
]`))
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
repos, err := c.ListAccessibleRepos(context.Background(), "", 50)
if err != nil {
t.Fatalf("ListAccessibleRepos: %v", err)
}
if gotAffiliation != "owner,collaborator,organization_member" {
t.Errorf("affiliation = %q, want owner,collaborator,organization_member", gotAffiliation)
}
if gotSort != "pushed" {
t.Errorf("sort = %q, want pushed", gotSort)
}
if gotPerPage != "50" {
t.Errorf("per_page = %q, want 50", gotPerPage)
}
if len(repos) != 2 {
t.Fatalf("repos = %d, want 2", len(repos))
}
if repos[0].FullName != "kdlbs/kandev" || repos[0].Owner != "kdlbs" || repos[0].DefaultBranch != "main" {
t.Errorf("unexpected first repo: %#v", repos[0])
}
if repos[0].Description != "the app" {
t.Errorf("first repo description = %q, want 'the app'", repos[0].Description)
}
if repos[0].PushedAt == nil {
t.Errorf("first repo PushedAt nil, want non-nil")
}
if !repos[1].Private || repos[1].DefaultBranch != "trunk" {
t.Errorf("unexpected second repo: %#v", repos[1])
}
// JSON null description must decode to empty string.
if repos[1].Description != "" {
t.Errorf("second repo description = %q, want empty", repos[1].Description)
}
}
func TestListAccessibleRepos_QueryFilterAndClamp(t *testing.T) {
var gotPerPage string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/repos" {
t.Errorf("unexpected path %q", r.URL.Path)
http.Error(w, "unexpected path", http.StatusInternalServerError)
return
}
gotPerPage = r.URL.Query().Get("per_page")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`[
{"full_name":"acme/widget","owner":{"login":"acme"},"name":"widget"},
{"full_name":"acme/gadget","owner":{"login":"acme"},"name":"gadget"},
{"full_name":"u/other","owner":{"login":"u"},"name":"other"}
]`))
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
// limit above cap must clamp per_page to 100; query filters client-side.
repos, err := c.ListAccessibleRepos(context.Background(), "WIDGET", 5000)
if err != nil {
t.Fatalf("ListAccessibleRepos: %v", err)
}
if gotPerPage != "100" {
t.Errorf("per_page = %q, want 100 (clamped)", gotPerPage)
}
if len(repos) != 1 || repos[0].FullName != "acme/widget" {
t.Fatalf("got %v, want [acme/widget] (case-insensitive substring on full_name)", repos)
}
}
func TestListAccessibleRepos_AuthError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/repos" {
t.Errorf("unexpected path %q", r.URL.Path)
}
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"bad creds"}`))
}))
t.Cleanup(srv.Close)
c := newPATClientPointingAt(t, srv.URL)
repos, err := c.ListAccessibleRepos(context.Background(), "", 0)
if err == nil {
t.Fatal("expected error on 401")
}
if repos != nil {
t.Errorf("expected nil repos on error, got %v", repos)
}
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized {
t.Errorf("err = %v, want *GitHubAPIError with 401", err)
}
}