package github import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "testing" "gitea.weiker.me/rodin/review-bot/vcs" ) // --- PostReview tests --- func TestPostReview_HappyPath(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Fatalf("expected POST, got %s", r.Method) } if r.URL.Path != "/repos/owner/repo/pulls/5/reviews" { t.Fatalf("unexpected path: %s", r.URL.Path) } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("expected Content-Type application/json, got %q", r.Header.Get("Content-Type")) } // Verify request body body, _ := io.ReadAll(r.Body) var req postReviewRequest if err := json.Unmarshal(body, &req); err != nil { t.Fatalf("unmarshal request: %v", err) } if req.Event != "APPROVE" { t.Errorf("expected event APPROVE, got %q", req.Event) } if req.Body != "LGTM" { t.Errorf("expected body 'LGTM', got %q", req.Body) } if req.CommitID != "abc123" { t.Errorf("expected commit_id 'abc123', got %q", req.CommitID) } if len(req.Comments) != 1 { t.Fatalf("expected 1 comment, got %d", len(req.Comments)) } if req.Comments[0].Path != "main.go" { t.Errorf("expected comment path 'main.go', got %q", req.Comments[0].Path) } if req.Comments[0].Position != 4 { t.Errorf("expected comment position 4, got %d", req.Comments[0].Position) } json.NewEncoder(w).Encode(map[string]interface{}{ "id": 100, "body": "LGTM", "state": "APPROVED", "commit_id": "abc123", "user": map[string]string{"login": "reviewer"}, }) }) review, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{ Body: "LGTM", Event: vcs.ReviewEventApprove, Comments: []vcs.ReviewComment{ {Path: "main.go", Position: 4, CommitID: "abc123", Body: "nit: rename"}, }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if review.ID != 100 { t.Errorf("expected ID 100, got %d", review.ID) } if review.Body != "LGTM" { t.Errorf("expected body 'LGTM', got %q", review.Body) } if review.State != "APPROVED" { t.Errorf("expected state 'APPROVED', got %q", review.State) } if review.User.Login != "reviewer" { t.Errorf("expected user 'reviewer', got %q", review.User.Login) } if review.CommitID != "abc123" { t.Errorf("expected commit_id 'abc123', got %q", review.CommitID) } } func TestPostReview_401(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(401) w.Write([]byte(`{"message":"Bad credentials"}`)) }) _, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{ Body: "LGTM", Event: vcs.ReviewEventApprove, }) if err == nil { t.Fatal("expected error for 401") } if !IsUnauthorized(err) { t.Errorf("expected IsUnauthorized=true, got error: %v", err) } } func TestPostReview_422(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(422) w.Write([]byte(`{"message":"Unprocessable Entity"}`)) }) _, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{ Body: "LGTM", Event: vcs.ReviewEventApprove, }) if err == nil { t.Fatal("expected error for 422") } // 422 should surface as a wrapped APIError var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("expected *APIError, got %T: %v", err, err) } if apiErr.StatusCode != 422 { t.Errorf("expected status 422, got %d", apiErr.StatusCode) } } func TestPostReview_MalformedResponse(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`not json`)) }) _, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{ Body: "LGTM", Event: vcs.ReviewEventApprove, }) if err == nil { t.Fatal("expected error for malformed response") } if !strings.Contains(err.Error(), "parse review response") { t.Errorf("expected parse error, got: %v", err) } } // --- ListReviews tests --- func TestListReviews_HappyPath(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Fatalf("expected GET, got %s", r.Method) } if r.URL.Path != "/repos/owner/repo/pulls/3/reviews" { t.Fatalf("unexpected path: %s", r.URL.Path) } json.NewEncoder(w).Encode([]map[string]interface{}{ { "id": 1, "body": "Approved", "state": "APPROVED", "commit_id": "sha1", "user": map[string]string{"login": "user1"}, }, { "id": 2, "body": "Needs work", "state": "CHANGES_REQUESTED", "commit_id": "sha2", "user": map[string]string{"login": "user2"}, }, { "id": 3, "body": "Comment only", "state": "COMMENTED", "commit_id": "sha3", "user": map[string]string{"login": "user3"}, }, { "id": 4, "body": "Old review", "state": "DISMISSED", "commit_id": "sha4", "user": map[string]string{"login": "user4"}, }, }) }) reviews, err := c.ListReviews(context.Background(), "owner", "repo", 3) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(reviews) != 4 { t.Fatalf("expected 4 reviews, got %d", len(reviews)) } // Check state translation expected := []struct { id int64 state string }{ {1, "APPROVED"}, {2, "REQUEST_CHANGES"}, {3, "COMMENT"}, {4, "DISMISSED"}, } for i, e := range expected { if reviews[i].ID != e.id { t.Errorf("review[%d]: expected ID %d, got %d", i, e.id, reviews[i].ID) } if reviews[i].State != e.state { t.Errorf("review[%d]: expected state %q, got %q", i, e.state, reviews[i].State) } } } func TestListReviews_404(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) w.Write([]byte(`{"message":"Not Found"}`)) }) _, err := c.ListReviews(context.Background(), "owner", "repo", 999) if err == nil { t.Fatal("expected error for 404") } if !IsNotFound(err) { t.Errorf("expected IsNotFound=true, got error: %v", err) } } func TestListReviews_401(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(401) w.Write([]byte(`{"message":"Bad credentials"}`)) }) _, err := c.ListReviews(context.Background(), "owner", "repo", 3) if err == nil { t.Fatal("expected error for 401") } if !IsUnauthorized(err) { t.Errorf("expected IsUnauthorized=true, got error: %v", err) } } // --- DeleteReview tests --- func TestDeleteReview_HappyPath(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "DELETE" { t.Fatalf("expected DELETE, got %s", r.Method) } if r.URL.Path != "/repos/owner/repo/pulls/5/reviews/42" { t.Fatalf("unexpected path: %s", r.URL.Path) } w.WriteHeader(204) }) err := c.DeleteReview(context.Background(), "owner", "repo", 5, 42) if err != nil { t.Fatalf("unexpected error: %v", err) } } func TestDeleteReview_422_SubmittedReview(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(422) w.Write([]byte(`{"message":"Can not delete a non pending review"}`)) }) err := c.DeleteReview(context.Background(), "owner", "repo", 5, 42) if err == nil { t.Fatal("expected error for 422") } if !errors.Is(err, ErrCannotDeleteSubmittedReview) { t.Errorf("expected ErrCannotDeleteSubmittedReview, got: %v", err) } } // --- DismissReview tests --- func TestDismissReview_HappyPath(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "PUT" { t.Fatalf("expected PUT, got %s", r.Method) } if r.URL.Path != "/repos/owner/repo/pulls/5/reviews/10/dismissals" { t.Fatalf("unexpected path: %s", r.URL.Path) } body, _ := io.ReadAll(r.Body) var req dismissReviewRequest if err := json.Unmarshal(body, &req); err != nil { t.Fatalf("unmarshal request: %v", err) } if req.Message != "Superseded by new review" { t.Errorf("expected message 'Superseded by new review', got %q", req.Message) } if req.Event != "DISMISS" { t.Errorf("expected event 'DISMISS', got %q", req.Event) } json.NewEncoder(w).Encode(map[string]interface{}{ "id": 10, "state": "DISMISSED", }) }) err := c.DismissReview(context.Background(), "owner", "repo", 5, 10, "Superseded by new review") if err != nil { t.Fatalf("unexpected error: %v", err) } } func TestDismissReview_404(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) w.Write([]byte(`{"message":"Not Found"}`)) }) err := c.DismissReview(context.Background(), "owner", "repo", 5, 999, "dismiss") if err == nil { t.Fatal("expected error for 404") } if !IsNotFound(err) { t.Errorf("expected IsNotFound=true, got error: %v", err) } } func TestDismissReview_401(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(401) w.Write([]byte(`{"message":"Bad credentials"}`)) }) err := c.DismissReview(context.Background(), "owner", "repo", 5, 10, "dismiss") if err == nil { t.Fatal("expected error for 401") } if !IsUnauthorized(err) { t.Errorf("expected IsUnauthorized=true, got error: %v", err) } } // --- State translation tests --- func TestTranslateGitHubReviewState(t *testing.T) { tests := []struct { name string input string want string }{ {"approved passes through", "APPROVED", "APPROVED"}, {"changes_requested maps to REQUEST_CHANGES", "CHANGES_REQUESTED", "REQUEST_CHANGES"}, {"commented maps to COMMENT", "COMMENTED", "COMMENT"}, {"dismissed passes through", "DISMISSED", "DISMISSED"}, {"unknown state passes through", "UNKNOWN_STATE", "UNKNOWN_STATE"}, {"empty string passes through", "", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := translateGitHubReviewState(tt.input) if got != tt.want { t.Errorf("translateGitHubReviewState(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestPostReview_ConflictingCommitIDs(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("request should not be sent when commit IDs conflict") }) _, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{ Body: "Review", Event: vcs.ReviewEventComment, Comments: []vcs.ReviewComment{ {Path: "a.go", Position: 1, CommitID: "sha-1", Body: "first"}, {Path: "b.go", Position: 2, CommitID: "sha-2", Body: "second"}, }, }) if err == nil { t.Fatal("expected error for conflicting commit IDs") } if !errors.Is(err, ErrConflictingCommitIDs) { t.Errorf("expected ErrConflictingCommitIDs, got: %v", err) } } func TestPostReview_RequestCommitID_TakesPriority(t *testing.T) { var gotPayload struct { CommitID string `json:"commit_id"` Body string `json:"body"` } c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&gotPayload) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "id": 42, "body": "LGTM", "state": "APPROVED", "commit_id": "req-level-sha", "user": map[string]any{"login": "bot"}, }) }) review, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{ Body: "LGTM", Event: vcs.ReviewEventApprove, CommitID: "req-level-sha", Comments: []vcs.ReviewComment{ {Path: "a.go", Position: 1, CommitID: "req-level-sha", Body: "looks good"}, }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if gotPayload.CommitID != "req-level-sha" { t.Errorf("sent commit_id = %q, want %q", gotPayload.CommitID, "req-level-sha") } if review.CommitID != "req-level-sha" { t.Errorf("review.CommitID = %q, want %q", review.CommitID, "req-level-sha") } } func TestPostReview_RequestCommitID_ConflictsWithComment(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("request should not be sent when commit IDs conflict") }) // req.CommitID is set, and a comment has a different CommitID → conflict _, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{ Body: "Review", Event: vcs.ReviewEventComment, CommitID: "req-sha", Comments: []vcs.ReviewComment{ {Path: "a.go", Position: 1, CommitID: "different-sha", Body: "nit"}, }, }) if err == nil { t.Fatal("expected error for conflicting commit IDs") } if !errors.Is(err, ErrConflictingCommitIDs) { t.Errorf("expected ErrConflictingCommitIDs, got: %v", err) } } func TestPostReview_RequestCommitID_FallbackToComment(t *testing.T) { var gotPayload struct { CommitID string `json:"commit_id"` } c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&gotPayload) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "id": 43, "body": "ok", "state": "COMMENTED", "commit_id": "comment-sha", "user": map[string]any{"login": "bot"}, }) }) // req.CommitID is empty, so it falls back to the comment's CommitID _, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{ Body: "ok", Event: vcs.ReviewEventComment, // CommitID intentionally empty Comments: []vcs.ReviewComment{ {Path: "a.go", Position: 1, CommitID: "comment-sha", Body: "note"}, }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if gotPayload.CommitID != "comment-sha" { t.Errorf("sent commit_id = %q, want %q (fallback from comment)", gotPayload.CommitID, "comment-sha") } } // --- ListReviews pagination tests --- func TestListReviews_MultiPage(t *testing.T) { // Test multi-page pagination: 2 full pages + 1 partial page. // pageSize=3, so pages return [3, 3, 2] reviews = 8 total. const pageSize = 3 callCount := 0 c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Fatalf("expected GET, got %s", r.Method) } callCount++ page := r.URL.Query().Get("page") var reviews []map[string]interface{} switch page { case "1": for i := 1; i <= pageSize; i++ { reviews = append(reviews, map[string]interface{}{ "id": i, "body": fmt.Sprintf("review %d", i), "state": "APPROVED", "commit_id": "sha1", "user": map[string]string{"login": "user1"}, }) } case "2": for i := pageSize + 1; i <= pageSize*2; i++ { reviews = append(reviews, map[string]interface{}{ "id": i, "body": fmt.Sprintf("review %d", i), "state": "COMMENTED", "commit_id": "sha1", "user": map[string]string{"login": "user2"}, }) } case "3": // Partial page: only 2 reviews (less than pageSize) for i := pageSize*2 + 1; i <= pageSize*2+2; i++ { reviews = append(reviews, map[string]interface{}{ "id": i, "body": fmt.Sprintf("review %d", i), "state": "CHANGES_REQUESTED", "commit_id": "sha1", "user": map[string]string{"login": "user3"}, }) } default: t.Fatalf("unexpected page: %s", page) } json.NewEncoder(w).Encode(reviews) }) c.SetReviewPagination(pageSize, 10) reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(reviews) != 8 { t.Fatalf("expected 8 reviews, got %d", len(reviews)) } if callCount != 3 { t.Errorf("expected 3 API calls, got %d", callCount) } // Verify reviews are correctly concatenated in order for i, r := range reviews { expectedID := int64(i + 1) if r.ID != expectedID { t.Errorf("review[%d]: expected ID %d, got %d", i, expectedID, r.ID) } } } func TestListReviews_ExactMultipleOfPageSize(t *testing.T) { // When total reviews is an exact multiple of pageSize, an extra request // returning 0 results terminates the loop. No truncation warning. const pageSize = 2 callCount := 0 c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { callCount++ page := r.URL.Query().Get("page") var reviews []map[string]interface{} switch page { case "1": reviews = []map[string]interface{}{ {"id": 1, "body": "r1", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u1"}}, {"id": 2, "body": "r2", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u2"}}, } case "2": reviews = []map[string]interface{}{ {"id": 3, "body": "r3", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u3"}}, {"id": 4, "body": "r4", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u4"}}, } case "3": // Empty page — signals end of data reviews = []map[string]interface{}{} default: t.Fatalf("unexpected page: %s", page) } json.NewEncoder(w).Encode(reviews) }) c.SetReviewPagination(pageSize, 10) reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(reviews) != 4 { t.Fatalf("expected 4 reviews, got %d", len(reviews)) } // 3 calls: page 1 (full), page 2 (full), page 3 (empty) if callCount != 3 { t.Errorf("expected 3 API calls, got %d", callCount) } } func TestListReviews_MaxPagesCutoff(t *testing.T) { // When maxPages is hit and the last page is full, results are truncated // and a warning would fire (we verify the reviews are still returned). const pageSize = 2 const maxPages = 2 callCount := 0 c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { callCount++ page := r.URL.Query().Get("page") // Always return a full page (simulating more data exists) var reviews []map[string]interface{} var baseID int switch page { case "1": baseID = 0 case "2": baseID = pageSize default: t.Fatalf("unexpected page %s (should not exceed maxPages)", page) } for i := 1; i <= pageSize; i++ { reviews = append(reviews, map[string]interface{}{ "id": baseID + i, "body": fmt.Sprintf("r%d", baseID+i), "state": "APPROVED", "commit_id": "sha1", "user": map[string]string{"login": "user"}, }) } json.NewEncoder(w).Encode(reviews) }) c.SetReviewPagination(pageSize, maxPages) reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1) if err != nil { t.Fatalf("unexpected error: %v", err) } // Should return all reviews fetched within the cap expectedCount := pageSize * maxPages if len(reviews) != expectedCount { t.Fatalf("expected %d reviews, got %d", expectedCount, len(reviews)) } if callCount != maxPages { t.Errorf("expected %d API calls, got %d", maxPages, callCount) } // Verify concatenation order for i, r := range reviews { if r.ID != int64(i+1) { t.Errorf("review[%d]: expected ID %d, got %d", i, i+1, r.ID) } } } func TestListReviews_EmptyFirstPage(t *testing.T) { // PR with no reviews: first page returns empty array. callCount := 0 c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { callCount++ json.NewEncoder(w).Encode([]map[string]interface{}{}) }) c.SetReviewPagination(10, 5) reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(reviews) != 0 { t.Fatalf("expected 0 reviews, got %d", len(reviews)) } if callCount != 1 { t.Errorf("expected 1 API call, got %d", callCount) } }