Files
kandev/apps/backend/internal/github/pat_client_writes_test.go
2026-08-31 11:25:58 +00:00

639 lines
24 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"time"
)
func TestPATClient_SubmitReview_SendsEventAndOptionalBody(t *testing.T) {
cases := []struct {
name string
event string
body string
wantJSON map[string]string
}{
{"approve without a body", "APPROVE", "", map[string]string{"event": "APPROVE"}},
{
"request changes with a body", "REQUEST_CHANGES", "please fix",
map[string]string{"event": "REQUEST_CHANGES", "body": "please fix"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusOK, `{}`
})
if err := c.SubmitReview(context.Background(), "acme", "widget", 42, tc.event, tc.body); err != nil {
t.Fatalf("SubmitReview: %v", err)
}
got := (*requests)[0]
if got.Method != http.MethodPost {
t.Errorf("method = %q, want POST", got.Method)
}
if got.Path != "/repos/acme/widget/pulls/42/reviews" {
t.Errorf("path = %q", got.Path)
}
if got.Header.Get("Content-Type") != "application/json" {
t.Errorf("content-type = %q, want application/json", got.Header.Get("Content-Type"))
}
var payload map[string]string
if err := json.Unmarshal([]byte(got.Body), &payload); err != nil {
t.Fatalf("decode request body %q: %v", got.Body, err)
}
if len(payload) != len(tc.wantJSON) {
t.Fatalf("payload = %#v, want %#v", payload, tc.wantJSON)
}
for k, v := range tc.wantJSON {
if payload[k] != v {
t.Errorf("payload[%q] = %q, want %q", k, payload[k], v)
}
}
})
}
}
func TestPATClient_SubmitReview_ErrorIncludesStatusAndBody(t *testing.T) {
c, _ := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusUnprocessableEntity, `{"message":"Can not approve your own pull request"}`
})
err := c.SubmitReview(context.Background(), "acme", "widget", 42, "APPROVE", "")
if err == nil {
t.Fatal("expected an error from 422")
}
if !strings.Contains(err.Error(), "422") || !strings.Contains(err.Error(), "own pull request") {
t.Errorf("err = %v, want the status and upstream body surfaced", err)
}
}
func TestPATClient_MergePR(t *testing.T) {
t.Run("sends the merge method", func(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusOK, `{"status":"merged"}`
})
if _, err := c.MergePR(context.Background(), "acme", "widget", 42, MergePRRequest{MergeMethod: "squash"}); err != nil {
t.Fatalf("MergePR: %v", err)
}
got := (*requests)[0]
if got.Method != http.MethodPut {
t.Errorf("method = %q, want PUT", got.Method)
}
if got.Path != "/repos/acme/widget/pulls/42/merge-async" {
t.Errorf("path = %q", got.Path)
}
if got.Body != `{"merge_action":"default","merge_method":"squash"}` {
t.Errorf("body = %q, want queue-aware merge payload", got.Body)
}
})
t.Run("omits the merge method when unset", func(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusOK, `{"status":"merged"}`
})
if _, err := c.MergePR(context.Background(), "acme", "widget", 42, MergePRRequest{}); err != nil {
t.Fatalf("MergePR: %v", err)
}
if (*requests)[0].Body != `{"merge_action":"default"}` {
t.Errorf("body = %q, want GitHub to choose direct or queued merge",
(*requests)[0].Body)
}
})
t.Run("sends the expected head SHA", func(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusOK, `{"status":"merged"}`
})
request := MergePRRequest{MergeMethod: "squash", ExpectedHeadSHA: "abc123"}
if _, err := c.MergePR(context.Background(), "acme", "widget", 42, request); err != nil {
t.Fatalf("MergePR: %v", err)
}
if got := (*requests)[0].Body; got != `{"merge_action":"default","merge_method":"squash","sha":"abc123"}` {
t.Errorf("body = %q, want expected head SHA", got)
}
})
}
// A merge rejection must stay recoverable as *GitHubAPIError so the HTTP
// layer can map 405 (not mergeable) and 409 (conflict) onto a 409 response.
func TestPATClient_MergePR_StatusIsRecoverable(t *testing.T) {
for _, status := range []int{http.StatusMethodNotAllowed, http.StatusConflict} {
t.Run(http.StatusText(status), func(t *testing.T) {
c, _ := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return status, `{"message":"Pull Request is not mergeable"}`
})
_, err := c.MergePR(context.Background(), "acme", "widget", 42, MergePRRequest{MergeMethod: "merge"})
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want a *GitHubAPIError", err)
}
if apiErr.StatusCode != status {
t.Errorf("status = %d, want %d", apiErr.StatusCode, status)
}
if apiErr.Endpoint != "/repos/acme/widget/pulls/42/merge-async" {
t.Errorf("endpoint = %q", apiErr.Endpoint)
}
if !strings.Contains(apiErr.Body, "not mergeable") {
t.Errorf("body = %q, want the upstream message", apiErr.Body)
}
})
}
}
func TestPATClient_MergePR_AlreadyQueuedIsIdempotent(t *testing.T) {
c, _ := newRecordingPATServerFunc(t, func(r *http.Request) (int, string) {
if r.Method == http.MethodGet {
return http.StatusOK, `{"status":"enqueued","details":{"message":"Pull request was added to the merge queue."}}`
}
return http.StatusConflict, `{"status":"pending","details":{"message":"Merge request enqueued.","uuid":"request-1","merge_method":"squash","merge_action":"default","expected_head_sha":"abc123"}}`
})
outcome, err := c.MergePR(context.Background(), "acme", "widget", 42, MergePRRequest{MergeMethod: "squash"})
if err != nil || outcome != MergeOutcomeQueued {
t.Fatalf("outcome = %q, err = %v, want queued", outcome, err)
}
}
// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-001.3
func TestPATClient_MergePR_PollsNestedPendingResponse(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(r *http.Request) (int, string) {
if r.Method == http.MethodGet {
return http.StatusOK, `{"status":"merged","details":{"sha":"abc123"}}`
}
return http.StatusAccepted, `{"status":"pending","details":{"message":"Merge request enqueued.","uuid":"request-1","merge_method":"squash","merge_action":"default","expected_head_sha":"abc123"}}`
})
outcome, err := c.MergePR(context.Background(), "acme", "widget", 42, MergePRRequest{MergeMethod: "squash"})
if err != nil || outcome != MergeOutcomeMerged {
t.Fatalf("outcome = %q, err = %v, want merged", outcome, err)
}
if len(*requests) != 2 || (*requests)[1].Path != "/repos/acme/widget/pulls/42/merge-async/request-1" {
t.Fatalf("requests = %#v, want a poll for request-1", *requests)
}
}
// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-002.7
func TestPATClient_MergePR_PendingPollHonorsContextBeforeNextRequest(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusAccepted, `{"status":"pending","details":{"uuid":"request-1"}}`
})
var gotDelay time.Duration
c.mergePollWait = func(_ context.Context, delay time.Duration) error {
gotDelay = delay
return context.DeadlineExceeded
}
_, err := c.MergePR(context.Background(), "acme", "widget", 42, MergePRRequest{MergeMethod: "squash"})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("MergePR error = %v, want context deadline", err)
}
if got := len(*requests); got != 1 {
t.Fatalf("request count = %d, want only the initial request before cancellation", got)
}
if gotDelay != time.Second {
t.Fatalf("poll delay = %s, want one second", gotDelay)
}
}
// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-001.13
func TestPATClient_MergePR_ReportsNestedFailureMessage(t *testing.T) {
c, _ := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusOK, `{"status":"failed","details":{"message":"Branch protection blocked the merge.","expected_head_sha":"abc123"}}`
})
_, err := c.MergePR(context.Background(), "acme", "widget", 42, MergePRRequest{MergeMethod: "squash"})
if err == nil || !strings.Contains(err.Error(), "Branch protection blocked the merge.") ||
!strings.Contains(err.Error(), "abc123") {
t.Fatalf("err = %v, want the nested provider message and expected head", err)
}
}
func TestPATClient_CreateGist(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusCreated, `{"id":"abc123","html_url":"https://gist.github.com/abc123",
"created_at":"2026-07-01T12:00:00Z"}`
})
resp, err := c.CreateGist(context.Background(), CreateGistInput{
Description: "kandev share",
Public: false,
Files: map[string]GistFile{"README.md": {Content: "# hello"}},
})
if err != nil {
t.Fatalf("CreateGist: %v", err)
}
if resp.ID != "abc123" || resp.HTMLURL != "https://gist.github.com/abc123" {
t.Errorf("response = %#v", resp)
}
if !resp.CreatedAt.Equal(time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)) {
t.Errorf("created_at = %v", resp.CreatedAt)
}
got := (*requests)[0]
if got.Method != http.MethodPost || got.Path != "/gists" {
t.Errorf("request = %s %s, want POST /gists", got.Method, got.Path)
}
var payload struct {
Description string `json:"description"`
Public bool `json:"public"`
Files map[string]map[string]string `json:"files"`
}
if err := json.Unmarshal([]byte(got.Body), &payload); err != nil {
t.Fatalf("decode payload %q: %v", got.Body, err)
}
if payload.Description != "kandev share" || payload.Public {
t.Errorf("payload meta = %#v", payload)
}
if payload.Files["README.md"]["content"] != "# hello" {
t.Errorf("payload files = %#v", payload.Files)
}
}
func TestPATClient_CreateGist_ErrorIsTyped(t *testing.T) {
c, _ := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusUnauthorized, `{"message":"Bad credentials"}`
})
resp, err := c.CreateGist(context.Background(), CreateGistInput{Files: map[string]GistFile{}})
if resp != nil {
t.Errorf("resp = %#v, want nil", resp)
}
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized {
t.Fatalf("err = %v, want a *GitHubAPIError with 401", err)
}
}
func TestPATClient_DeleteGist(t *testing.T) {
t.Run("deletes by id", func(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusNoContent, ""
})
if err := c.DeleteGist(context.Background(), "abc123"); err != nil {
t.Fatalf("DeleteGist: %v", err)
}
got := (*requests)[0]
if got.Method != http.MethodDelete || got.Path != "/gists/abc123" {
t.Errorf("request = %s %s, want DELETE /gists/abc123", got.Method, got.Path)
}
if got.Header.Get("Authorization") != "token test-token" {
t.Errorf("DELETE must still carry auth, got %q", got.Header.Get("Authorization"))
}
})
t.Run("empty id never reaches the network", func(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusNoContent, ""
})
if err := c.DeleteGist(context.Background(), ""); err == nil {
t.Fatal("expected an error for an empty gist id")
}
if len(*requests) != 0 {
t.Errorf("requests = %d, want 0", len(*requests))
}
})
// share.IsAlreadyGone matches on the typed 404 to treat an already-revoked
// gist as a soft success rather than a 502.
t.Run("404 stays recoverable as a typed error", func(t *testing.T) {
c, _ := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusNotFound, `{"message":"Not Found"}`
})
err := c.DeleteGist(context.Background(), "gone")
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotFound {
t.Fatalf("err = %v, want a *GitHubAPIError with 404", err)
}
if apiErr.Endpoint != "/gists/gone" {
t.Errorf("endpoint = %q, want /gists/gone", apiErr.Endpoint)
}
})
}
func TestPATClient_ListPRReviews(t *testing.T) {
c, requests := newRecordingPATServer(t, map[string]string{
"/repos/acme/widget/pulls/42/reviews": `[
{"id":1,"state":"APPROVED","body":"lgtm","submitted_at":"2026-01-05T10:00:00Z",
"user":{"login":"alice","avatar_url":"https://avatars/alice"}},
{"id":2,"state":"CHANGES_REQUESTED","body":"nope","submitted_at":"2026-01-06T10:00:00Z",
"user":{"login":"bob","avatar_url":""}}
]`,
})
reviews, err := c.ListPRReviews(context.Background(), "acme", "widget", 42)
if err != nil {
t.Fatalf("ListPRReviews: %v", err)
}
if len(reviews) != 2 {
t.Fatalf("reviews = %d, want 2", len(reviews))
}
want := PRReview{
ID: 1, Author: "alice", AuthorAvatar: "https://avatars/alice",
State: "APPROVED", Body: "lgtm",
CreatedAt: time.Date(2026, 1, 5, 10, 0, 0, 0, time.UTC),
}
if reviews[0] != want {
t.Errorf("reviews[0] = %#v, want %#v", reviews[0], want)
}
if reviews[1].ID != 2 || reviews[1].Author != "bob" || reviews[1].State != "CHANGES_REQUESTED" {
t.Errorf("reviews[1] = %#v", reviews[1])
}
if (*requests)[0].Path != "/repos/acme/widget/pulls/42/reviews" {
t.Errorf("path = %q", (*requests)[0].Path)
}
}
func TestPATClient_ListPRComments_MergesReviewAndIssueCommentsInTimeOrder(t *testing.T) {
c, requests := newRecordingPATServer(t, map[string]string{
"/repos/acme/widget/pulls/42/comments": `[
{"id":20,"path":"main.go","line":7,"side":"RIGHT","body":"inline note",
"created_at":"2026-01-05T12:00:00Z","updated_at":"2026-01-05T12:30:00Z",
"in_reply_to_id":19,"html_url":"https://github.com/acme/widget/pull/42#discussion_r20",
"user":{"login":"alice","avatar_url":"https://a","type":"User"}}
]`,
"/repos/acme/widget/issues/42/comments": `[
{"id":10,"body":"conversation note","created_at":"2026-01-05T09:00:00Z",
"updated_at":"2026-01-05T09:00:00Z","html_url":"https://github.com/acme/widget/pull/42#issuecomment-10",
"user":{"login":"dependabot","avatar_url":"https://d","type":"Bot"}}
]`,
})
comments, err := c.ListPRComments(context.Background(), "acme", "widget", 42, nil)
if err != nil {
t.Fatalf("ListPRComments: %v", err)
}
if len(comments) != 2 {
t.Fatalf("comments = %d, want 2", len(comments))
}
// The issue comment is older, so it must sort first.
first, second := comments[0], comments[1]
if first.ID != 10 || first.CommentType != commentTypeIssue {
t.Errorf("comments[0] = %#v, want the older issue comment", first)
}
assertPRCommentHTMLURL(t, first, "https://github.com/acme/widget/pull/42#issuecomment-10")
if !first.AuthorIsBot {
t.Error("a Bot-typed user must set AuthorIsBot")
}
if second.ID != 20 || second.CommentType != commentTypeReview {
t.Errorf("comments[1] = %#v, want the review comment", second)
}
if second.Path != "main.go" || second.Line != 7 || second.Side != "RIGHT" {
t.Errorf("review comment location = %#v", second)
}
if second.InReplyTo == nil || *second.InReplyTo != 19 {
t.Errorf("in_reply_to = %v, want 19", second.InReplyTo)
}
assertPRCommentHTMLURL(t, second, "https://github.com/acme/widget/pull/42#discussion_r20")
if second.AuthorIsBot {
t.Error("a User-typed author must not set AuthorIsBot")
}
for _, req := range *requests {
if !strings.Contains(req.Query, "per_page=100") {
t.Errorf("%s query = %q, want per_page=100", req.Path, req.Query)
}
if strings.Contains(req.Query, "since=") {
t.Errorf("%s query = %q, want no since when the caller passed nil", req.Path, req.Query)
}
}
}
func TestPATClient_ListPRComments_AppendsSinceToBothEndpoints(t *testing.T) {
c, requests := newRecordingPATServer(t, map[string]string{
"/repos/acme/widget/pulls/42/comments": `[]`,
"/repos/acme/widget/issues/42/comments": `[]`,
})
since := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
if _, err := c.ListPRComments(context.Background(), "acme", "widget", 42, &since); err != nil {
t.Fatalf("ListPRComments: %v", err)
}
if len(*requests) != 2 {
t.Fatalf("requests = %d, want 2", len(*requests))
}
for _, req := range *requests {
if got := parseQueryValues(t, req.Query).Get("since"); got != "2026-01-02T03:04:05Z" {
t.Errorf("%s since = %q, want the RFC3339 timestamp", req.Path, got)
}
}
}
// The issue-comment leg runs after the review leg; its failure must abort the
// whole call rather than silently returning a half-populated list.
func TestPATClient_ListPRComments_IssueLegFailureAborts(t *testing.T) {
c, _ := newRecordingPATServer(t, map[string]string{
"/repos/acme/widget/pulls/42/comments": `[]`,
})
comments, err := c.ListPRComments(context.Background(), "acme", "widget", 42, nil)
if err == nil {
t.Fatal("expected an error when the issue-comments call fails")
}
if comments != nil {
t.Errorf("comments = %#v, want nil", comments)
}
}
func TestPATClient_ListPRFiles(t *testing.T) {
c, requests := newRecordingPATServer(t, map[string]string{
"/repos/acme/widget/pulls/42/files": `[
{"filename":"main.go","status":"modified","additions":4,"deletions":1,"patch":"@@ -1 +1 @@\n-old\n+new"},
{"filename":"new.go","previous_filename":"old.go","status":"renamed","additions":0,"deletions":0}
]`,
})
files, err := c.ListPRFiles(context.Background(), "acme", "widget", 42)
if err != nil {
t.Fatalf("ListPRFiles: %v", err)
}
if len(files) != 2 {
t.Fatalf("files = %d, want 2", len(files))
}
if files[0].Filename != "main.go" || files[0].Status != "modified" {
t.Errorf("files[0] identity = %#v", files[0])
}
if files[0].Additions != 4 || files[0].Deletions != 1 {
t.Errorf("files[0] stats = (%d, %d), want (4, 1)", files[0].Additions, files[0].Deletions)
}
if !strings.Contains(files[0].Patch, "+new") {
t.Errorf("files[0] patch = %q, want the hunk body preserved", files[0].Patch)
}
if files[1].OldPath != "old.go" || files[1].Status != "renamed" {
t.Errorf("files[1] = %#v, want the rename source preserved", files[1])
}
if !strings.Contains((*requests)[0].Query, "per_page=100") {
t.Errorf("query = %q, want per_page=100", (*requests)[0].Query)
}
}
func TestPATClient_ListPRFiles_Error(t *testing.T) {
c, _ := newRecordingPATServer(t, nil)
files, err := c.ListPRFiles(context.Background(), "acme", "widget", 42)
if files != nil {
t.Errorf("files = %#v, want nil", files)
}
if err == nil || !strings.Contains(err.Error(), "list PR files") {
t.Fatalf("err = %v, want a wrapped list-PR-files error", err)
}
}
func TestPATClient_ListPRCommits(t *testing.T) {
c, requests := newRecordingPATServer(t, map[string]string{
"/repos/acme/widget/pulls/42/commits": `[
{"sha":"aaa","commit":{"message":"feat: add thing\n\nlong body","author":{"date":"2026-01-07T10:00:00Z"}},
"author":{"login":"alice"}},
{"sha":"bbb","commit":{"message":"fix: typo","author":{"date":"2026-01-08T10:00:00Z"}}}
]`,
})
commits, err := c.ListPRCommits(context.Background(), "acme", "widget", 42)
if err != nil {
t.Fatalf("ListPRCommits: %v", err)
}
if len(commits) != 2 {
t.Fatalf("commits = %d, want 2", len(commits))
}
if commits[0].SHA != "aaa" || commits[0].AuthorLogin != "alice" {
t.Errorf("commits[0] identity = %#v", commits[0])
}
if commits[0].Message != "feat: add thing" {
t.Errorf("commits[0] message = %q, want only the subject line", commits[0].Message)
}
if commits[0].AuthorDate != "2026-01-07T10:00:00Z" {
t.Errorf("commits[0] author date = %q", commits[0].AuthorDate)
}
if commits[0].StatsAvailable {
t.Error("the PR-commits list carries no per-commit stats; StatsAvailable must stay false")
}
// A commit with no matched GitHub account decodes with an empty login.
if commits[1].AuthorLogin != "" || commits[1].Message != "fix: typo" {
t.Errorf("commits[1] = %#v", commits[1])
}
if !strings.Contains((*requests)[0].Query, "per_page=100") {
t.Errorf("query = %q, want per_page=100", (*requests)[0].Query)
}
}
func TestPATClient_ListPRCommits_FollowsLinkHeaderPages(t *testing.T) {
c, requests := newLinkPaginatedPATServer(t, "/repos/acme/widget/pulls/42/commits", []string{
`[
{"sha":"aaa","commit":{"message":"first","author":{"date":"2026-01-07T10:00:00Z"}}},
{"sha":"bbb","commit":{"message":"second","author":{"date":"2026-01-08T10:00:00Z"}}}
]`,
`[
{"sha":"ccc","commit":{"message":"provider head","author":{"date":"2026-01-09T10:00:00Z"}}}
]`,
})
commits, err := c.ListPRCommits(context.Background(), "acme", "widget", 42)
if err != nil {
t.Fatalf("ListPRCommits: %v", err)
}
if len(commits) != 3 {
t.Fatalf("commits = %d, want all pages (3)", len(commits))
}
if commits[2].SHA != "ccc" {
t.Errorf("last commit = %q, want the second-page provider head", commits[2].SHA)
}
if len(*requests) != 2 {
t.Fatalf("requests = %d, want 2 pages", len(*requests))
}
}
func TestPATClient_ListPRCommits_Error(t *testing.T) {
c, _ := newRecordingPATServer(t, nil)
if _, err := c.ListPRCommits(context.Background(), "acme", "widget", 42); err == nil ||
!strings.Contains(err.Error(), "list PR commits") {
t.Fatalf("err = %v, want a wrapped list-PR-commits error", err)
}
}
func TestPATClient_ListRepoBranches_FollowsLinkHeaderPages(t *testing.T) {
c, requests := newLinkPaginatedPATServer(t, "/repos/acme/widget/branches", []string{
`[{"name":"main"},{"name":"develop"}]`,
`[{"name":"release/1.0"}]`,
})
branches, err := c.ListRepoBranches(context.Background(), "acme", "widget")
if err != nil {
t.Fatalf("ListRepoBranches: %v", err)
}
want := []string{"main", "develop", "release/1.0"}
if len(branches) != len(want) {
t.Fatalf("branches = %#v, want %v", branches, want)
}
for i, name := range want {
if branches[i].Name != name {
t.Errorf("branches[%d] = %q, want %q", i, branches[i].Name, name)
}
}
if len(*requests) != 2 {
t.Fatalf("requests = %d, want 2 pages", len(*requests))
}
if !strings.Contains((*requests)[0].Query, "per_page=100") {
t.Errorf("first query = %q, want per_page=100", (*requests)[0].Query)
}
}
func TestPATClient_ListRepoBranches_Error(t *testing.T) {
c, _ := newRecordingPATServer(t, nil)
branches, err := c.ListRepoBranches(context.Background(), "acme", "widget")
if branches != nil {
t.Errorf("branches = %#v, want nil", branches)
}
if err == nil || !strings.Contains(err.Error(), "list repo branches") {
t.Fatalf("err = %v, want a wrapped list-repo-branches error", err)
}
}
// Missing allow_* fields must read as false: a permission-gated repo response
// that omits them would otherwise let the caller pick a disallowed method and
// take a 405 from the merge endpoint.
func TestPATClient_GetRepoMergeMethods(t *testing.T) {
cases := []struct {
name string
body string
want RepoMergeMethods
}{
{
"all allowed",
`{"allow_merge_commit":true,"allow_squash_merge":true,"allow_rebase_merge":true}`,
RepoMergeMethods{Merge: true, Squash: true, Rebase: true},
},
{
"squash only",
`{"allow_merge_commit":false,"allow_squash_merge":true,"allow_rebase_merge":false}`,
RepoMergeMethods{Squash: true},
},
{
"omitted fields read as disallowed",
`{"full_name":"acme/widget"}`,
RepoMergeMethods{},
},
{
"explicit nulls read as disallowed",
`{"allow_merge_commit":null,"allow_squash_merge":null,"allow_rebase_merge":null}`,
RepoMergeMethods{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, requests := newRecordingPATServer(t, map[string]string{"/repos/acme/widget": tc.body})
got, err := c.GetRepoMergeMethods(context.Background(), "acme", "widget")
if err != nil {
t.Fatalf("GetRepoMergeMethods: %v", err)
}
if got != tc.want {
t.Errorf("methods = %#v, want %#v", got, tc.want)
}
if (*requests)[0].Path != "/repos/acme/widget" {
t.Errorf("path = %q", (*requests)[0].Path)
}
})
}
}
func TestPATClient_GetRepoMergeMethods_Error(t *testing.T) {
c, _ := newRecordingPATServer(t, nil)
got, err := c.GetRepoMergeMethods(context.Background(), "acme", "widget")
if got != (RepoMergeMethods{}) {
t.Errorf("methods = %#v, want the zero value on error", got)
}
if err == nil || !strings.Contains(err.Error(), "get repo merge methods") {
t.Fatalf("err = %v, want a wrapped merge-methods error", err)
}
}