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") } }