package gitea import ( "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/http/httptest" "strings" "sync/atomic" "syscall" "testing" "time" ) func TestGetPullRequest(t *testing.T) { pr := PullRequest{ Title: "Add feature X", Body: "This adds feature X.", } pr.Head.Sha = "abc123" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/owner/repo/pulls/1" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.Header.Get("Authorization") != "token test-token" { t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization")) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(pr) })) defer server.Close() client := NewClient(server.URL, "test-token") got, err := client.GetPullRequest(context.Background(), "owner", "repo", 1) if err != nil { t.Fatalf("unexpected error: %v", err) } if got.Title != "Add feature X" { t.Errorf("expected title %q, got %q", "Add feature X", got.Title) } if got.Body != "This adds feature X." { t.Errorf("expected body %q, got %q", "This adds feature X.", got.Body) } if got.Head.Sha != "abc123" { t.Errorf("expected sha %q, got %q", "abc123", got.Head.Sha) } } func TestGetPullRequestDiff(t *testing.T) { expectedDiff := "diff --git a/file.go b/file.go\n--- a/file.go\n+++ b/file.go\n@@ -1 +1 @@\n-old\n+new\n" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/owner/repo/pulls/5.diff" { t.Errorf("unexpected path: %s", r.URL.Path) } w.Write([]byte(expectedDiff)) })) defer server.Close() client := NewClient(server.URL, "test-token") got, err := client.GetPullRequestDiff(context.Background(), "owner", "repo", 5) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != expectedDiff { t.Errorf("expected diff %q, got %q", expectedDiff, got) } } func TestGetCommitStatuses(t *testing.T) { statuses := []CommitStatus{ {Status: "success", Context: "ci/test", Description: "All tests passed"}, {Status: "failure", Context: "ci/lint", Description: "Lint failed"}, } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/owner/repo/commits/abc123/statuses" { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(statuses) })) defer server.Close() client := NewClient(server.URL, "test-token") got, err := client.GetCommitStatuses(context.Background(), "owner", "repo", "abc123") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(got) != 2 { t.Fatalf("expected 2 statuses, got %d", len(got)) } if got[0].Status != "success" { t.Errorf("expected first status %q, got %q", "success", got[0].Status) } if got[1].Status != "failure" { t.Errorf("expected second status %q, got %q", "failure", got[1].Status) } } func TestPostReview(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/api/v1/repos/owner/repo/pulls/3/reviews" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("unexpected content type: %s", r.Header.Get("Content-Type")) } var payload struct { Body string `json:"body"` Event string `json:"event"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("failed to decode payload: %v", err) } if payload.Body != "LGTM" { t.Errorf("expected body %q, got %q", "LGTM", payload.Body) } if payload.Event != "APPROVED" { t.Errorf("expected event %q, got %q", "APPROVED", payload.Event) } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"id":100,"user":{"login":"review-bot"},"state":"APPROVED","stale":false}`)) })) defer server.Close() client := NewClient(server.URL, "test-token") review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if review.ID != 100 { t.Errorf("expected review ID 100, got %d", review.ID) } if review.User.Login != "review-bot" { t.Errorf("expected user login %q, got %q", "review-bot", review.User.Login) } } func TestGetPullRequest_Non200(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{"message":"not found"}`)) })) defer server.Close() client := NewClient(server.URL, "test-token") _, err := client.GetPullRequest(context.Background(), "owner", "repo", 999) if err == nil { t.Fatal("expected error for 404, got nil") } } func TestGetPullRequest_BadJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`not json`)) })) defer server.Close() client := NewClient(server.URL, "test-token") _, err := client.GetPullRequest(context.Background(), "owner", "repo", 1) if err == nil { t.Fatal("expected error for bad JSON, got nil") } } func TestPostReview_Non200(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) w.Write([]byte(`{"message":"forbidden"}`)) })) defer server.Close() client := NewClient(server.URL, "test-token") _, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", nil) if err == nil { t.Fatal("expected error for 403, got nil") } } func TestGetFileContent(t *testing.T) { expected := "# Conventions\n- Be nice\n" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/owner/repo/raw/CONVENTIONS.md" { t.Errorf("unexpected path: %s", r.URL.Path) } w.Write([]byte(expected)) })) defer server.Close() client := NewClient(server.URL, "test-token") got, err := client.GetFileContent(context.Background(), "owner", "repo", "CONVENTIONS.md") if err != nil { t.Fatalf("unexpected error: %v", err) } if got != expected { t.Errorf("expected %q, got %q", expected, got) } } func TestGetPullRequestFiles(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/owner/repo/pulls/1/files" { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`[{"filename":"main.go","status":"modified"},{"filename":"old.go","status":"removed"}]`)) })) defer server.Close() client := NewClient(server.URL, "test-token") files, err := client.GetPullRequestFiles(context.Background(), "owner", "repo", 1) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(files) != 2 { t.Fatalf("expected 2 files, got %d", len(files)) } if files[0].Filename != "main.go" || files[0].Status != "modified" { t.Errorf("unexpected first file: %+v", files[0]) } } func TestGetFileContentRef(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/owner/repo/raw/main.go" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.URL.Query().Get("ref") != "feature-branch" { t.Errorf("unexpected ref: %s", r.URL.Query().Get("ref")) } w.Write([]byte("package main\n")) })) defer server.Close() client := NewClient(server.URL, "test-token") content, err := client.GetFileContentRef(context.Background(), "owner", "repo", "main.go", "feature-branch") if err != nil { t.Fatalf("unexpected error: %v", err) } if content != "package main\n" { t.Errorf("unexpected content: %q", content) } } func TestListContents(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/owner/repo/contents/docs" { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `[{"name":"guide.md","path":"docs/guide.md","type":"file"},{"name":"sub","path":"docs/sub","type":"dir"}]`) })) defer server.Close() client := NewClient(server.URL, "test-token") entries, err := client.ListContents(context.Background(), "owner", "repo", "docs") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(entries) != 2 { t.Fatalf("expected 2 entries, got %d", len(entries)) } if entries[0].Type != "file" || entries[0].Path != "docs/guide.md" { t.Errorf("unexpected first entry: %+v", entries[0]) } if entries[1].Type != "dir" { t.Errorf("expected dir type, got %s", entries[1].Type) } } func TestListContents_DotPath(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // "." should be normalized to empty path, which hits the root contents endpoint if r.URL.Path != "/api/v1/repos/owner/repo/contents" { t.Errorf("expected root contents path, got: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `[{"name":"README.md","path":"README.md","type":"file"}]`) })) defer server.Close() client := NewClient(server.URL, "test-token") entries, err := client.ListContents(context.Background(), "owner", "repo", ".") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(entries)) } if entries[0].Name != "README.md" { t.Errorf("expected README.md, got %s", entries[0].Name) } } func TestListContents_FilePath(t *testing.T) { // Gitea returns a single object (not an array) when path is a file server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/owner/repo/contents/README.md" { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") // Single object, not an array fmt.Fprintf(w, `{"name":"README.md","path":"README.md","type":"file"}`) })) defer server.Close() client := NewClient(server.URL, "test-token") entries, err := client.ListContents(context.Background(), "owner", "repo", "README.md") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(entries)) } if entries[0].Name != "README.md" { t.Errorf("expected README.md, got %s", entries[0].Name) } if entries[0].Type != "file" { t.Errorf("expected type file, got %s", entries[0].Type) } } func TestGetAllFilesInPath_File(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v1/repos/owner/repo/contents/README.md" { // Gitea returns a single object (not array) when path is a file w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"name":"README.md","path":"README.md","type":"file"}`) return } if r.URL.Path == "/api/v1/repos/owner/repo/raw/README.md" { fmt.Fprintf(w, "# Hello") return } http.NotFound(w, r) })) defer server.Close() client := NewClient(server.URL, "test-token") files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(files) != 1 { t.Fatalf("expected 1 file, got %d", len(files)) } if files["README.md"] != "# Hello" { t.Errorf("unexpected content: %q", files["README.md"]) } } func TestEscapePath(t *testing.T) { tests := []struct { name string input string want string }{ {"simple", "src/main.go", "src/main.go"}, {"spaces", "my dir/my file.go", "my%20dir/my%20file.go"}, {"special chars", "path/file#1.txt", "path/file%231.txt"}, {"empty", "", ""}, {"single segment", "README.md", "README.md"}, {"nested deep", "a/b/c/d.md", "a/b/c/d.md"}, {"already encoded", "path/file%20name.go", "path/file%2520name.go"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := escapePath(tt.input) if got != tt.want { t.Errorf("escapePath(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestListReviews(t *testing.T) { pageCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/owner/repo/pulls/5/reviews" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.URL.Query().Get("limit") != "50" { t.Errorf("expected limit=50, got %s", r.URL.Query().Get("limit")) } pageCount++ w.Header().Set("Content-Type", "application/json") // Return 2 results (less than page size) to signal end w.Write([]byte(`[{"id":10,"user":{"login":"bot-a"},"state":"APPROVED","stale":false},{"id":11,"user":{"login":"bot-b"},"state":"REQUEST_CHANGES","stale":true}]`)) })) defer server.Close() client := NewClient(server.URL, "test-token") reviews, err := client.ListReviews(context.Background(), "owner", "repo", 5) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(reviews) != 2 { t.Fatalf("expected 2 reviews, got %d", len(reviews)) } if reviews[0].User.Login != "bot-a" { t.Errorf("expected bot-a, got %s", reviews[0].User.Login) } if pageCount != 1 { t.Errorf("expected 1 page fetch (results < page size), got %d", pageCount) } } func TestListReviews_Pagination(t *testing.T) { pageCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { pageCount++ page := r.URL.Query().Get("page") w.Header().Set("Content-Type", "application/json") if page == "1" { // Return exactly 50 items to trigger next page fetch items := "[" for i := 0; i < 50; i++ { if i > 0 { items += "," } items += fmt.Sprintf(`{"id":%d,"user":{"login":"bot"},"state":"APPROVED","stale":false}`, i+1) } items += "]" w.Write([]byte(items)) } else { // Page 2: return fewer than 50 to signal end w.Write([]byte(`[{"id":51,"user":{"login":"bot"},"state":"APPROVED","stale":false}]`)) } })) defer server.Close() client := NewClient(server.URL, "test-token") reviews, err := client.ListReviews(context.Background(), "owner", "repo", 5) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(reviews) != 51 { t.Fatalf("expected 51 reviews across 2 pages, got %d", len(reviews)) } if pageCount != 2 { t.Errorf("expected 2 page fetches, got %d", pageCount) } } func TestDeleteReview(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/owner/repo/pulls/5/reviews/10" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.Method != "DELETE" { t.Errorf("expected DELETE, got %s", r.Method) } w.WriteHeader(http.StatusNoContent) })) defer server.Close() client := NewClient(server.URL, "test-token") err := client.DeleteReview(context.Background(), "owner", "repo", 5, 10) if err != nil { t.Fatalf("unexpected error: %v", err) } } func TestDeleteReview_Forbidden(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) w.Write([]byte(`{"message":"forbidden"}`)) })) defer server.Close() client := NewClient(server.URL, "test-token") err := client.DeleteReview(context.Background(), "owner", "repo", 5, 10) if err == nil { t.Fatal("expected error for 403, got nil") } } func TestEditComment(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { t.Errorf("expected PATCH, got %s", r.Method) } if r.URL.Path != "/api/v1/repos/owner/repo/issues/comments/42" { t.Errorf("unexpected path: %s", r.URL.Path) } var payload struct { Body string `json:"body"` } json.NewDecoder(r.Body).Decode(&payload) if payload.Body != "updated body" { t.Errorf("unexpected body: %s", payload.Body) } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"id": 42, "body": "updated body"}`)) })) defer server.Close() client := NewClient(server.URL, "test-token") err := client.EditComment(context.Background(), "owner", "repo", 42, "updated body") if err != nil { t.Fatalf("EditComment() error = %v", err) } } func TestEditComment_Forbidden(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) w.Write([]byte(`{"message": "not allowed"}`)) })) defer server.Close() client := NewClient(server.URL, "test-token") err := client.EditComment(context.Background(), "owner", "repo", 42, "new body") if err == nil { t.Fatal("expected error for 403 response") } } func TestGetTimelineReviewCommentID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/owner/repo/issues/5/timeline" { t.Errorf("unexpected path: %s", r.URL.Path) } w.Write([]byte(`[ {"id": 100, "type": "comment", "body": "random"}, {"id": 200, "type": "review", "body": "other review "}, {"id": 300, "type": "review", "body": "our review "} ]`)) })) defer server.Close() client := NewClient(server.URL, "test-token") id, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "") if err != nil { t.Fatalf("GetTimelineReviewCommentID() error = %v", err) } if id != 300 { t.Errorf("got id=%d, want 300", id) } } func TestGetTimelineReviewCommentID_NotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`[{"id": 100, "type": "review", "body": "no match"}]`)) })) defer server.Close() client := NewClient(server.URL, "test-token") _, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "") if err == nil { t.Fatal("expected error when sentinel not found") } } func TestGetAllFilesInPath_404FallsBackToFile(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/repos/owner/repo/contents/README.md": // Contents API returns 404 for files (not a directory) w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{"message":"not found"}`)) case "/api/v1/repos/owner/repo/raw/README.md": w.Write([]byte("# Hello\n")) default: w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{"message":"not found"}`)) } })) defer server.Close() client := NewClient(server.URL, "test-token") files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md") if err != nil { t.Fatalf("expected fallback to file on 404, got error: %v", err) } if len(files) != 1 { t.Fatalf("expected 1 file, got %d", len(files)) } if files["README.md"] != "# Hello\n" { t.Errorf("unexpected content: %q", files["README.md"]) } } func TestGetAllFilesInPath_500Propagates(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Simulate a server error from ListContents w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"message":"internal server error"}`)) })) defer server.Close() client := NewClient(server.URL, "test-token") _, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "somepath") if err == nil { t.Fatal("expected error to propagate for 500, got nil") } // Should NOT fall back to file fetch — error should propagate var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("expected APIError in chain, got: %v", err) } if apiErr.StatusCode != http.StatusInternalServerError { t.Errorf("expected status 500, got %d", apiErr.StatusCode) } } func TestGetAllFilesInPath_403Propagates(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) w.Write([]byte(`{"message":"token has insufficient scope"}`)) })) defer server.Close() client := NewClient(server.URL, "test-token") _, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "private/stuff") if err == nil { t.Fatal("expected error to propagate for 403, got nil") } var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("expected APIError in chain, got: %v", err) } if apiErr.StatusCode != http.StatusForbidden { t.Errorf("expected status 403, got %d", apiErr.StatusCode) } } func TestIsNotFound(t *testing.T) { tests := []struct { name string err error want bool }{ {"nil error", nil, false}, {"non-API error", fmt.Errorf("network timeout"), false}, {"404 APIError", &APIError{StatusCode: 404, Body: "not found"}, true}, {"500 APIError", &APIError{StatusCode: 500, Body: "server error"}, false}, {"wrapped 404", fmt.Errorf("list contents: %w", &APIError{StatusCode: 404, Body: "not found"}), true}, {"wrapped 500", fmt.Errorf("list contents: %w", &APIError{StatusCode: 500, Body: "err"}), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := IsNotFound(tt.err) if got != tt.want { t.Errorf("IsNotFound(%v) = %v, want %v", tt.err, got, tt.want) } }) } } func TestGetAuthenticatedUser(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/user" { t.Errorf("unexpected path: %s", r.URL.Path) http.NotFound(w, r) return } if r.Header.Get("Authorization") != "token test-token" { t.Error("missing or wrong auth header") } w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"login":"my-bot","id":42}`) })) defer server.Close() client := NewClient(server.URL, "test-token") login, err := client.GetAuthenticatedUser(context.Background()) if err != nil { t.Fatalf("GetAuthenticatedUser() error = %v", err) } if login != "my-bot" { t.Errorf("login = %q, want %q", login, "my-bot") } } func TestRequestReviewer(t *testing.T) { var gotBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } expected := "/api/v1/repos/owner/repo/pulls/7/requested_reviewers" if r.URL.Path != expected { t.Errorf("path = %q, want %q", r.URL.Path, expected) } gotBody, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusCreated) })) defer server.Close() client := NewClient(server.URL, "test-token") err := client.RequestReviewer(context.Background(), "owner", "repo", 7, "bot-user") if err != nil { t.Fatalf("RequestReviewer() error = %v", err) } if !strings.Contains(string(gotBody), `"bot-user"`) { t.Errorf("body = %s, want to contain bot-user", gotBody) } } func TestRequestReviewer_204(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) defer server.Close() client := NewClient(server.URL, "test-token") err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user") if err != nil { t.Fatalf("RequestReviewer() should accept 204, got error = %v", err) } } func TestRequestReviewer_Error(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) fmt.Fprint(w, "no permission") })) defer server.Close() client := NewClient(server.URL, "test-token") err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user") if err == nil { t.Fatal("expected error for 403 response") } if !strings.Contains(err.Error(), "403") { t.Errorf("error should mention status code: %v", err) } } func TestListReviewComments(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.URL.Path, "/pulls/1/reviews/42/comments") { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `[{"id":100,"path":"main.go","new_position":5,"body":"finding"},{"id":101,"path":"lib.go","new_position":10,"body":"another"}]`) })) defer server.Close() client := NewClient(server.URL, "test-token") comments, err := client.ListReviewComments(context.Background(), "owner", "repo", 1, 42) if err != nil { t.Fatalf("ListReviewComments() error = %v", err) } if len(comments) != 2 { t.Fatalf("got %d comments, want 2", len(comments)) } if comments[0].ID != 100 { t.Errorf("comments[0].ID = %d, want 100", comments[0].ID) } if comments[1].Path != "lib.go" { t.Errorf("comments[1].Path = %q, want %q", comments[1].Path, "lib.go") } } func TestResolveComment(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/pulls/comments/99/resolve") { t.Errorf("unexpected path: %s", r.URL.Path) } w.WriteHeader(http.StatusOK) })) defer server.Close() client := NewClient(server.URL, "test-token") err := client.ResolveComment(context.Background(), "owner", "repo", 99) if err != nil { t.Fatalf("ResolveComment() error = %v", err) } } func TestResolveComment_Error(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, "not found") })) defer server.Close() client := NewClient(server.URL, "test-token") err := client.ResolveComment(context.Background(), "owner", "repo", 99) if err == nil { t.Fatal("expected error for 404 response") } } func TestIsServerError(t *testing.T) { tests := []struct { name string err error want bool }{ {"nil error", nil, false}, {"non-API error", fmt.Errorf("network timeout"), false}, {"404 APIError", &APIError{StatusCode: 404, Body: "not found"}, false}, {"500 APIError", &APIError{StatusCode: 500, Body: "server error"}, true}, {"502 APIError", &APIError{StatusCode: 502, Body: "bad gateway"}, true}, {"503 APIError", &APIError{StatusCode: 503, Body: "unavailable"}, true}, {"599 APIError", &APIError{StatusCode: 599, Body: "edge case"}, true}, {"600 not server error", &APIError{StatusCode: 600, Body: "edge"}, false}, {"400 not server error", &APIError{StatusCode: 400, Body: "bad request"}, false}, {"wrapped 500", fmt.Errorf("fetch: %w", &APIError{StatusCode: 500, Body: "err"}), true}, {"wrapped 404", fmt.Errorf("fetch: %w", &APIError{StatusCode: 404, Body: "err"}), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := IsServerError(tt.err) if got != tt.want { t.Errorf("IsServerError(%v) = %v, want %v", tt.err, got, tt.want) } }) } } func TestDoGet_RetriesOn500(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ if attempts < 3 { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"message":"transient error"}`)) return } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"data":"success"}`)) })) defer server.Close() client := NewClient(server.URL, "test-token") // Use short backoff for fast tests client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond} body, err := client.doGet(context.Background(), server.URL+"/test") if err != nil { t.Fatalf("expected success after retry, got error: %v", err) } if string(body) != `{"data":"success"}` { t.Errorf("body = %q, want %q", string(body), `{"data":"success"}`) } if attempts != 3 { t.Errorf("attempts = %d, want 3", attempts) } } func TestDoGet_FailsAfterMaxRetries(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"message":"persistent error"}`)) })) defer server.Close() client := NewClient(server.URL, "test-token") // Use short backoff for fast tests client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond} _, err := client.doGet(context.Background(), server.URL+"/test") if err == nil { t.Fatal("expected error after max retries") } var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("expected APIError, got: %v", err) } if apiErr.StatusCode != http.StatusInternalServerError { t.Errorf("status = %d, want 500", apiErr.StatusCode) } if attempts != 3 { t.Errorf("attempts = %d, want 3 (max retries)", attempts) } } func TestDoGet_NoRetryOn4xx(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ w.WriteHeader(http.StatusForbidden) w.Write([]byte(`{"message":"forbidden"}`)) })) defer server.Close() client := NewClient(server.URL, "test-token") _, err := client.doGet(context.Background(), server.URL+"/test") if err == nil { t.Fatal("expected error for 403") } var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("expected APIError, got: %v", err) } if apiErr.StatusCode != http.StatusForbidden { t.Errorf("status = %d, want 403", apiErr.StatusCode) } if attempts != 1 { t.Errorf("attempts = %d, want 1 (no retry on 4xx)", attempts) } } func TestDoGet_RespectsContextCancellation(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"message":"error"}`)) })) defer server.Close() ctx, cancel := context.WithCancel(context.Background()) client := NewClient(server.URL, "test-token") // Use longer backoff to give us time to cancel during the wait client.RetryBackoff = []time.Duration{100 * time.Millisecond, 100 * time.Millisecond} // Cancel after first attempt returns and retry begins go func() { time.Sleep(20 * time.Millisecond) cancel() }() _, err := client.doGet(ctx, server.URL+"/test") if err == nil { t.Fatal("expected error on context cancellation") } // Should have made 1 attempt, then context cancelled during backoff if attempts != 1 { t.Errorf("attempts = %d, expected 1 before context cancel during backoff", attempts) } } // mockTransport is a test helper that returns errors for the first N calls, // then delegates to a real server. type mockTransport struct { failCount int32 // number of failures remaining (atomic) failErr error // error to return on failure realServer *httptest.Server attemptsMade atomic.Int32 // tracks total attempts } func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { m.attemptsMade.Add(1) remaining := atomic.AddInt32(&m.failCount, -1) if remaining >= 0 { // Still have failures to return return nil, m.failErr } // Redirect to real server req.URL.Host = m.realServer.Listener.Addr().String() req.URL.Scheme = "http" return http.DefaultTransport.RoundTrip(req) } func TestDoGet_RetriesOnTemporaryNetError(t *testing.T) { // Real server that will handle successful requests server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok"}`)) })) defer server.Close() // Mock transport: fail twice with ECONNREFUSED, then succeed mt := &mockTransport{ failCount: 2, failErr: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}, realServer: server, } client := NewClient("http://fake-host/", "test-token") client.SetHTTPClient(&http.Client{Transport: mt}) client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond} body, err := client.doGet(context.Background(), "http://fake-host/test") if err != nil { t.Fatalf("expected success after retries, got error: %v", err) } if string(body) != `{"status":"ok"}` { t.Errorf("body = %q, want %q", string(body), `{"status":"ok"}`) } // Should have made exactly 3 attempts: 2 failures + 1 success if got := mt.attemptsMade.Load(); got != 3 { t.Errorf("attempts = %d, want 3 (2 failures + 1 success)", got) } } func TestIsTemporaryNetError(t *testing.T) { tests := []struct { name string err error want bool }{ {"nil error", nil, false}, {"plain error", fmt.Errorf("some error"), false}, // OpError with retriable syscall errors {"OpError ECONNREFUSED", &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}, true}, {"OpError ECONNRESET", &net.OpError{Op: "read", Err: syscall.ECONNRESET}, true}, {"OpError ENETUNREACH", &net.OpError{Op: "dial", Err: syscall.ENETUNREACH}, true}, {"OpError EHOSTUNREACH", &net.OpError{Op: "dial", Err: syscall.EHOSTUNREACH}, true}, {"OpError ETIMEDOUT", &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT}, true}, // OpError with permanent syscall errors — should NOT retry {"OpError EACCES", &net.OpError{Op: "dial", Err: syscall.EACCES}, false}, {"OpError EPERM", &net.OpError{Op: "dial", Err: syscall.EPERM}, false}, // OpError with unknown inner error — conservative retry {"OpError unknown inner", &net.OpError{Op: "dial", Err: fmt.Errorf("unknown")}, true}, // DNS errors {"DNS timeout", &net.DNSError{IsTimeout: true}, true}, {"DNS no such host", &net.DNSError{IsTimeout: false, Name: "bad.host"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isTemporaryNetError(tt.err) if got != tt.want { t.Errorf("isTemporaryNetError(%v) = %v, want %v", tt.err, got, tt.want) } }) } } func TestIsRetriableSyscallError(t *testing.T) { tests := []struct { name string err error want bool }{ {"nil", nil, false}, {"ECONNREFUSED", syscall.ECONNREFUSED, true}, {"ECONNRESET", syscall.ECONNRESET, true}, {"ENETUNREACH", syscall.ENETUNREACH, true}, {"EHOSTUNREACH", syscall.EHOSTUNREACH, true}, {"ETIMEDOUT", syscall.ETIMEDOUT, true}, {"EACCES (permanent)", syscall.EACCES, false}, {"EPERM (permanent)", syscall.EPERM, false}, {"ENOENT (permanent)", syscall.ENOENT, false}, {"unknown error", fmt.Errorf("something"), true}, // conservative retry } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isRetriableSyscallError(tt.err) if got != tt.want { t.Errorf("isRetriableSyscallError(%v) = %v, want %v", tt.err, got, tt.want) } }) } } func TestRedactURL(t *testing.T) { tests := []struct { name string input string want string }{ { name: "no query params", input: "https://gitea.example.com/api/v1/repos/owner/repo/pulls/1", want: "https://gitea.example.com/api/v1/repos/owner/repo/pulls/1", }, { name: "with query params - redacts", input: "https://gitea.example.com/api/v1/repos/owner/repo/raw/file?ref=main", want: "https://gitea.example.com/api/v1/repos/owner/repo/raw/file?[redacted]", }, { name: "multiple query params", input: "https://example.com/path?token=secret&page=1", want: "https://example.com/path?[redacted]", }, { name: "invalid URL", input: "://invalid", want: "[invalid URL]", }, { name: "empty string", input: "", want: "", }, { name: "with userinfo - redacts credentials", input: "https://admin:secret@gitea.example.com/api/v1/repos", want: "https://REDACTED@gitea.example.com/api/v1/repos", }, { name: "with userinfo and query params", input: "https://user:pass@example.com/path?token=abc", want: "https://REDACTED@example.com/path?[redacted]", }, { name: "username only - no password", input: "https://user@example.com/path", want: "https://REDACTED@example.com/path", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := redactURL(tt.input) if got != tt.want { t.Errorf("redactURL(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestSanitizeErrorForLog(t *testing.T) { tests := []struct { name string err error want string }{ { name: "nil error", err: nil, want: "", }, { name: "APIError omits body", err: &APIError{StatusCode: 500, Body: "internal error: database connection failed"}, want: "HTTP 500", }, { name: "APIError with large body still only shows status", err: &APIError{StatusCode: 502, Body: strings.Repeat("x", 1000)}, want: "HTTP 502", }, { name: "non-API error preserved", err: fmt.Errorf("connection refused"), want: "connection refused", }, { name: "wrapped APIError", err: fmt.Errorf("request failed: %w", &APIError{StatusCode: 503, Body: "service unavailable"}), want: "HTTP 503", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := sanitizeErrorForLog(tt.err) if got != tt.want { t.Errorf("sanitizeErrorForLog() = %q, want %q", got, tt.want) } }) } }