e59c2bc831
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 25s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 42s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 57s
Add commitID parameter to gitea.Client.PostReview so the review is anchored to the specific commit that was evaluated. The caller (cmd/review-bot) already computes evaluatedSHA from pr.Head.Sha; this wires it through to the Gitea API payload. When commitID is empty, omitempty drops it from the JSON and Gitea defaults to the current PR head (backward-compatible). Closes #107
1288 lines
41 KiB
Go
1288 lines
41 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"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"`
|
|
CommitID string `json:"commit_id"`
|
|
}
|
|
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)
|
|
}
|
|
if payload.CommitID != "abc123def" {
|
|
t.Errorf("expected commit_id %q, got %q", "abc123def", payload.CommitID)
|
|
}
|
|
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", "abc123def", 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 TestPostReview_EmptyCommitID_OmittedFromPayload(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
t.Fatalf("failed to decode payload: %v", err)
|
|
}
|
|
if _, exists := raw["commit_id"]; exists {
|
|
t.Errorf("expected commit_id to be omitted from payload when empty, but it was present")
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"id":200,"user":{"login":"bot"},"state":"APPROVED","stale":false}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "ok", "", nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
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 <!-- review-bot:gpt -->"},
|
|
{"id": 300, "type": "review", "body": "our review <!-- review-bot:sonnet -->"}
|
|
]`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
id, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
|
|
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, "<!-- review-bot:sonnet -->")
|
|
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: "<nil>",
|
|
},
|
|
{
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewClient_HasCheckRedirect(t *testing.T) {
|
|
c := NewClient("https://gitea.example.com", "token")
|
|
if c.http.CheckRedirect == nil {
|
|
t.Fatal("expected CheckRedirect to be set")
|
|
}
|
|
}
|
|
|
|
func TestDefaultCheckRedirect_RejectsHTTPSToHTTP(t *testing.T) {
|
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
|
req := &http.Request{
|
|
URL: &url.URL{Scheme: "http", Host: "gitea.example.com", Path: "/foo"},
|
|
Header: http.Header{"Authorization": []string{"token abc"}},
|
|
}
|
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
|
if err == nil {
|
|
t.Fatal("expected error on HTTPS->HTTP redirect")
|
|
}
|
|
if !strings.Contains(err.Error(), "HTTPS to HTTP downgrade") {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDefaultCheckRedirect_RejectsCrossHost(t *testing.T) {
|
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
|
req := &http.Request{
|
|
URL: &url.URL{Scheme: "https", Host: "cdn.example.com", Path: "/bar"},
|
|
Header: http.Header{"Authorization": []string{"token abc"}},
|
|
}
|
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
|
if err == nil {
|
|
t.Fatal("expected error on cross-host redirect")
|
|
}
|
|
if !strings.Contains(err.Error(), "cross-host") {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDefaultCheckRedirect_AllowsSameHost(t *testing.T) {
|
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
|
req := &http.Request{
|
|
URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/bar"},
|
|
Header: http.Header{"Authorization": []string{"token abc"}},
|
|
}
|
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if auth := req.Header.Get("Authorization"); auth != "token abc" {
|
|
t.Errorf("expected Authorization to be preserved, got %q", auth)
|
|
}
|
|
}
|
|
|
|
func TestDefaultCheckRedirect_AllowsSameHostHTTPToHTTP(t *testing.T) {
|
|
prev := &http.Request{URL: &url.URL{Scheme: "http", Host: "localhost:3000", Path: "/foo"}}
|
|
req := &http.Request{
|
|
URL: &url.URL{Scheme: "http", Host: "localhost:3000", Path: "/bar"},
|
|
Header: http.Header{},
|
|
}
|
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDefaultCheckRedirect_RejectsTooManyRedirects(t *testing.T) {
|
|
via := make([]*http.Request, 10)
|
|
for i := range via {
|
|
via[i] = &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/"}}
|
|
}
|
|
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/final"}}
|
|
err := defaultCheckRedirect(req, via)
|
|
if err == nil {
|
|
t.Fatal("expected error after 10 redirects")
|
|
}
|
|
if !strings.Contains(err.Error(), "10 redirects") {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDefaultCheckRedirect_EmptyViaAllowed(t *testing.T) {
|
|
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
|
err := defaultCheckRedirect(req, nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error with empty via: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
|
|
c := NewClient("https://gitea.example.com", "token")
|
|
c.SetHTTPClient(nil)
|
|
if c.http == nil {
|
|
t.Fatal("expected non-nil http client after SetHTTPClient(nil)")
|
|
}
|
|
if c.http.Timeout != 30*time.Second {
|
|
t.Errorf("expected 30s timeout, got %v", c.http.Timeout)
|
|
}
|
|
if c.http.CheckRedirect == nil {
|
|
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
|
}
|
|
}
|