Files
review-bot/github/api_test.go
T
claw 8be12602f0
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m3s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m7s
fix(#130): cleanup and test improvements
- Fix extra blank lines between GetPullRequest and GetPullRequestFiles
- Add TestPostReview_CommitIDFromRequest to verify CommitID passes through
2026-05-14 13:57:58 -07:00

583 lines
18 KiB
Go

package github
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gitea.weiker.me/rodin/review-bot/vcs"
)
func TestGetPullRequest(t *testing.T) {
want := PullRequest{
Title: "My PR",
Body: "Description",
}
want.Head.Sha = "abc123"
want.Head.Ref = "feature/foo"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/pulls/42" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(want)
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
pr, err := c.GetPullRequest(context.Background(), "owner", "repo", 42)
if err != nil {
t.Fatalf("GetPullRequest: %v", err)
}
if pr.Title != want.Title {
t.Errorf("Title = %q, want %q", pr.Title, want.Title)
}
if pr.Head.Sha != want.Head.Sha {
t.Errorf("Head.Sha = %q, want %q", pr.Head.Sha, want.Head.Sha)
}
if pr.Head.Ref != want.Head.Ref {
t.Errorf("Head.Ref = %q, want %q", pr.Head.Ref, want.Head.Ref)
}
}
func TestGetPullRequest_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
_, err := c.GetPullRequest(context.Background(), "owner", "repo", 1)
if err == nil {
t.Fatal("expected error, got nil")
}
if !IsNotFound(err) {
t.Errorf("expected IsNotFound, got %v", err)
}
}
func TestGetPullRequestDiff(t *testing.T) {
const wantDiff = "diff --git a/foo.go b/foo.go\n+added line\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") != "application/vnd.github.diff" {
t.Errorf("Accept = %q, want application/vnd.github.diff", r.Header.Get("Accept"))
}
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, wantDiff)
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
diff, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("GetPullRequestDiff: %v", err)
}
if diff != wantDiff {
t.Errorf("diff = %q, want %q", diff, wantDiff)
}
}
func TestGetPullRequestDiff_TooLarge(t *testing.T) {
// Return a diff larger than DefaultMaxDiffSize.
hugeDiff := make([]byte, DefaultMaxDiffSize+1)
for i := range hugeDiff {
hugeDiff[i] = 'x'
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write(hugeDiff)
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
_, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 1)
if err == nil {
t.Fatal("expected ErrDiffTooLarge, got nil")
}
if err != ErrDiffTooLarge {
t.Errorf("err = %v, want ErrDiffTooLarge", err)
}
}
func TestGetPullRequestFiles(t *testing.T) {
files := []ChangedFile{
{Filename: "foo.go", Status: "modified"},
{Filename: "bar.go", Status: "added"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/pulls/7/files" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(files)
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
got, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 7)
if err != nil {
t.Fatalf("GetPullRequestFiles: %v", err)
}
if len(got) != len(files) {
t.Fatalf("len = %d, want %d", len(got), len(files))
}
for i, f := range files {
if got[i].Filename != f.Filename || got[i].Status != f.Status {
t.Errorf("file[%d] = %+v, want %+v", i, got[i], f)
}
}
}
func TestGetCommitStatuses(t *testing.T) {
statuses := []CommitStatus{
{Status: "success", Context: "ci/tests", Description: "All tests passed", TargetURL: "https://ci.example.com"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/commits/deadbeef/statuses" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(statuses)
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
got, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "deadbeef")
if err != nil {
t.Fatalf("GetCommitStatuses: %v", err)
}
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if got[0].Status != "success" || got[0].Context != "ci/tests" {
t.Errorf("status = %+v, want %+v", got[0], statuses[0])
}
}
func TestGetFileContent(t *testing.T) {
const wantContent = "package main\n\nfunc main() {}\n"
encoded := base64.StdEncoding.EncodeToString([]byte(wantContent))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/contents/main.go" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(contentResponse{
Type: "file",
Name: "main.go",
Path: "main.go",
Encoding: "base64",
Content: encoded,
})
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
got, err := c.GetFileContent(context.Background(), "owner", "repo", "main.go")
if err != nil {
t.Fatalf("GetFileContent: %v", err)
}
if got != wantContent {
t.Errorf("content = %q, want %q", got, wantContent)
}
}
func TestGetFileContentRef(t *testing.T) {
const wantContent = "version: 2\n"
encoded := base64.StdEncoding.EncodeToString([]byte(wantContent))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/contents/config.yml" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
if r.URL.Query().Get("ref") != "feature/x" {
t.Errorf("ref = %q, want feature/x", r.URL.Query().Get("ref"))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(contentResponse{
Type: "file",
Name: "config.yml",
Path: "config.yml",
Encoding: "base64",
Content: encoded,
})
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
got, err := c.GetFileContentRef(context.Background(), "owner", "repo", "config.yml", "feature/x")
if err != nil {
t.Fatalf("GetFileContentRef: %v", err)
}
if got != wantContent {
t.Errorf("content = %q, want %q", got, wantContent)
}
}
func TestGetFileContent_NewlinesInBase64(t *testing.T) {
// GitHub inserts newlines every 60 chars in the base64-encoded content.
// Verify we strip them before decoding.
const wantContent = "hello world this is a long string that gets split by github with newlines in the base64 encoding"
rawEncoded := base64.StdEncoding.EncodeToString([]byte(wantContent))
// Insert newlines to simulate GitHub's format.
var chunked string
for i := 0; i < len(rawEncoded); i += 60 {
end := i + 60
if end > len(rawEncoded) {
end = len(rawEncoded)
}
chunked += rawEncoded[i:end] + "\n"
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(contentResponse{
Type: "file",
Encoding: "base64",
Content: chunked,
})
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
got, err := c.GetFileContent(context.Background(), "owner", "repo", "file.txt")
if err != nil {
t.Fatalf("GetFileContent: %v", err)
}
if got != wantContent {
t.Errorf("content = %q, want %q", got, wantContent)
}
}
func TestListContents_Directory(t *testing.T) {
entries := []ContentEntry{
{Name: "main.go", Path: "main.go", Type: "file"},
{Name: "lib", Path: "lib", Type: "dir"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/contents/" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
got, err := c.ListContents(context.Background(), "owner", "repo", "")
if err != nil {
t.Fatalf("ListContents: %v", err)
}
if len(got) != len(entries) {
t.Fatalf("len = %d, want %d", len(got), len(entries))
}
for i, e := range entries {
if got[i].Name != e.Name || got[i].Type != e.Type {
t.Errorf("entry[%d] = %+v, want %+v", i, got[i], e)
}
}
}
func TestListContents_File(t *testing.T) {
// When path points to a single file, GitHub returns an object, not array.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Return a single file object (not an array).
json.NewEncoder(w).Encode(contentResponse{
Type: "file",
Name: "README.md",
Path: "README.md",
Encoding: "base64",
Content: base64.StdEncoding.EncodeToString([]byte("# Hello")),
})
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
got, err := c.ListContents(context.Background(), "owner", "repo", "README.md")
if err != nil {
t.Fatalf("ListContents (file): %v", err)
}
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if got[0].Name != "README.md" || got[0].Type != "file" {
t.Errorf("entry = %+v", got[0])
}
}
func TestGetAuthenticatedUser(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(userResponse{Login: "rodin"})
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
login, err := c.GetAuthenticatedUser(context.Background())
if err != nil {
t.Fatalf("GetAuthenticatedUser: %v", err)
}
if login != "rodin" {
t.Errorf("login = %q, want %q", login, "rodin")
}
}
func TestPostReview(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/5/reviews" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
var payload postReviewRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("decode body: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if payload.Event != "REQUEST_CHANGES" {
t.Errorf("event = %q, want REQUEST_CHANGES", payload.Event)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(reviewResponse{
ID: 99,
Body: payload.Body,
State: "CHANGES_REQUESTED",
User: struct{ Login string `json:"login"` }{Login: "rodin"},
})
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
review, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "needs work",
Event: vcs.ReviewEventRequestChanges,
})
if err != nil {
t.Fatalf("PostReview: %v", err)
}
if review.ID != 99 {
t.Errorf("review.ID = %d, want 99", review.ID)
}
// Verify state translation: CHANGES_REQUESTED -> REQUEST_CHANGES
if review.State != "REQUEST_CHANGES" {
t.Errorf("review.State = %q, want REQUEST_CHANGES", review.State)
}
if review.User.Login != "rodin" {
t.Errorf("review.User.Login = %q, want rodin", review.User.Login)
}
}
func TestPostReview_ConflictingCommitIDs(t *testing.T) {
c := NewClient("tok", "https://api.github.com")
_, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "test",
Event: vcs.ReviewEventComment,
Comments: []vcs.ReviewComment{
{Path: "a.go", Position: 1, Body: "comment 1", CommitID: "sha1"},
{Path: "b.go", Position: 2, Body: "comment 2", CommitID: "sha2"},
},
})
if err == nil {
t.Fatal("expected ErrConflictingCommitIDs, got nil")
}
if err != ErrConflictingCommitIDs {
t.Errorf("err = %v, want ErrConflictingCommitIDs", err)
}
}
func TestListReviews(t *testing.T) {
reviews := []reviewResponse{
{ID: 1, Body: "lgtm", State: "APPROVED", User: struct{ Login string `json:"login"` }{Login: "alice"}},
{ID: 2, Body: "needs work", State: "CHANGES_REQUESTED", User: struct{ Login string `json:"login"` }{Login: "bob"}},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/pulls/3/reviews" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(reviews)
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
got, err := c.ListReviews(context.Background(), "owner", "repo", 3)
if err != nil {
t.Fatalf("ListReviews: %v", err)
}
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].State != "APPROVED" {
t.Errorf("got[0].State = %q, want APPROVED", got[0].State)
}
if got[1].State != "REQUEST_CHANGES" {
t.Errorf("got[1].State = %q, want REQUEST_CHANGES (translated from CHANGES_REQUESTED)", got[1].State)
}
}
func TestDeleteReview_Pending(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("method = %s, want DELETE", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/1/reviews/42" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
err := c.DeleteReview(context.Background(), "owner", "repo", 1, 42)
if err != nil {
t.Fatalf("DeleteReview: %v", err)
}
}
func TestDeleteReview_Submitted_Returns422(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// GitHub returns 422 when trying to delete a submitted review.
w.WriteHeader(http.StatusUnprocessableEntity)
w.Write([]byte(`{"message":"Cannot delete a submitted review"}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
err := c.DeleteReview(context.Background(), "owner", "repo", 1, 42)
if err == nil {
t.Fatal("expected error for submitted review deletion")
}
// Should be wrapped as ErrCannotDeleteSubmittedReview.
if !isErrCannotDeleteSubmittedReview(err) {
t.Errorf("err = %v, want ErrCannotDeleteSubmittedReview", err)
}
}
// isErrCannotDeleteSubmittedReview checks if err wraps ErrCannotDeleteSubmittedReview.
func isErrCannotDeleteSubmittedReview(err error) bool {
return err != nil && errors.Is(err, ErrCannotDeleteSubmittedReview)
}
func TestDismissReview(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("method = %s, want PUT", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/2/reviews/10/dismissals" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
var payload dismissReviewRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("decode body: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if payload.Event != "DISMISS" {
t.Errorf("event = %q, want DISMISS", payload.Event)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(reviewResponse{ID: 10, State: "DISMISSED"})
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
err := c.DismissReview(context.Background(), "owner", "repo", 2, 10, "outdated review")
if err != nil {
t.Fatalf("DismissReview: %v", err)
}
}
func TestDoRequestWithBody_RejectsHTTP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("request should not have been sent")
}))
defer srv.Close()
// Without AllowInsecureHTTPForTest, HTTP should be rejected.
c := NewClient("tok", srv.URL)
_, err := c.doRequestWithBody(context.Background(), http.MethodPost, srv.URL+"/test", []byte(`{}`))
if err == nil {
t.Fatal("expected error for HTTP request")
}
}
func TestPostReview_CommitIDFromRequest(t *testing.T) {
const wantCommitID = "abc123def456"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload postReviewRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("decode body: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if payload.CommitID != wantCommitID {
t.Errorf("commit_id = %q, want %q", payload.CommitID, wantCommitID)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(reviewResponse{
ID: 1,
Body: payload.Body,
State: "COMMENTED",
CommitID: payload.CommitID,
User: struct{ Login string `json:"login"` }{Login: "rodin"},
})
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
review, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "looks good",
Event: vcs.ReviewEventApprove,
CommitID: wantCommitID,
})
if err != nil {
t.Fatalf("PostReview: %v", err)
}
if review.CommitID != wantCommitID {
t.Errorf("review.CommitID = %q, want %q", review.CommitID, wantCommitID)
}
}