Files
2026-06-23 05:02:15 +08:00

708 lines
21 KiB
Go

package gitea_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"superwork-tui/internal/gitea"
)
// newServer starts an httptest server using the given handler and returns a
// *gitea.Client pointed at it with a fixed token.
func newServer(t *testing.T, h http.Handler) (*httptest.Server, *gitea.Client) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return srv, gitea.New(srv.URL, "test-token")
}
// mustJSON marshals v or fatals the test.
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("mustJSON: %v", err)
}
return b
}
// --- Auth header -----------------------------------------------------------
func TestAuthHeader(t *testing.T) {
var gotAuth string
srv, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"login":"alice"}`)
}))
_ = srv
u, err := c.GetCurrentUser(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if u.Login != "alice" {
t.Errorf("login = %q, want alice", u.Login)
}
if gotAuth != "token test-token" {
t.Errorf("Authorization = %q, want %q", gotAuth, "token test-token")
}
}
// --- 401 → APIError --------------------------------------------------------
func TestGetCurrentUser_401(t *testing.T) {
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}))
_, err := c.GetCurrentUser(context.Background())
if err == nil {
t.Fatal("expected error, got nil")
}
var apiErr *gitea.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err is %T, want *gitea.APIError", err)
}
if apiErr.Status != 401 {
t.Errorf("Status = %d, want 401", apiErr.Status)
}
}
// --- GetIssue 404 → nil ----------------------------------------------------
func TestGetIssue_404_ReturnsNil(t *testing.T) {
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
issue, err := c.GetIssue(context.Background(), "owner", "repo", 99)
if err != nil {
t.Fatalf("expected nil error on 404, got: %v", err)
}
if issue != nil {
t.Errorf("expected nil issue on 404, got %+v", issue)
}
}
// --- GetIssue 200 ----------------------------------------------------------
func TestGetIssue_200(t *testing.T) {
payload := gitea.Issue{
Number: 7,
Title: "hello",
State: "open",
Body: "body text",
HtmlURL: "https://gitea.example.com/owner/repo/issues/7",
}
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}))
issue, err := c.GetIssue(context.Background(), "owner", "repo", 7)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if issue == nil {
t.Fatal("expected non-nil issue")
}
if issue.Number != 7 || issue.Title != "hello" {
t.Errorf("issue = %+v", issue)
}
}
// --- Pagination: ListAllRepoComments crosses 50-item boundary --------------
func TestListAllRepoComments_Pagination(t *testing.T) {
// Page 1: 50 comments, page 2: 3 comments → total 53.
page1 := make([]gitea.Comment, 50)
for i := range page1 {
page1[i] = gitea.Comment{ID: i + 1, Body: fmt.Sprintf("c%d", i+1)}
}
page2 := []gitea.Comment{
{ID: 51, Body: "c51"},
{ID: 52, Body: "c52"},
{ID: 53, Body: "c53"},
}
var pagesSeen []string
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Query().Get("page")
pagesSeen = append(pagesSeen, p)
w.Header().Set("Content-Type", "application/json")
if p == "1" || p == "" {
json.NewEncoder(w).Encode(page1)
} else {
json.NewEncoder(w).Encode(page2)
}
}))
comments, err := c.ListAllRepoComments(context.Background(), "owner", "repo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(comments) != 53 {
t.Errorf("len(comments) = %d, want 53", len(comments))
}
// Must have fetched exactly 2 pages.
if len(pagesSeen) != 2 {
t.Errorf("pages fetched = %v, want 2 pages", pagesSeen)
}
if comments[52].ID != 53 {
t.Errorf("last comment ID = %d, want 53", comments[52].ID)
}
}
// --- ListIssuesByFilter uses correct query params --------------------------
func TestListIssuesByFilter_QueryParams(t *testing.T) {
var capturedURL string
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedURL = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]gitea.Issue{{Number: 1, Title: "t1"}})
}))
issues, err := c.ListIssuesByFilter(context.Background(), "owner", "repo", "assigned_by", "alice")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(issues) != 1 {
t.Errorf("len = %d, want 1", len(issues))
}
for _, want := range []string{"type=issues", "state=all", "assigned_by=alice", "limit=50", "page=1"} {
if !strings.Contains(capturedURL, want) {
t.Errorf("query %q missing param %q", capturedURL, want)
}
}
}
// --- PostIssueComment sends correct body -----------------------------------
func TestPostIssueComment_Body(t *testing.T) {
var gotBody map[string]string
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("decode body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(gitea.Comment{ID: 99, Body: "hello world"})
}))
comment, err := c.PostIssueComment(context.Background(), "owner", "repo", 5, "hello world")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotBody["body"] != "hello world" {
t.Errorf("posted body = %q, want %q", gotBody["body"], "hello world")
}
if comment == nil || comment.ID != 99 {
t.Errorf("returned comment = %+v", comment)
}
}
// --- GetDependencies returns issues in order --------------------------------
func TestGetDependencies_Order(t *testing.T) {
deps := []gitea.Issue{
{Number: 10, Title: "dep-one"},
{Number: 20, Title: "dep-two"},
{Number: 30, Title: "dep-three"},
}
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/dependencies") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(deps)
}))
got, err := c.GetDependencies(context.Background(), "owner", "repo", 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
wantNums := []int{10, 20, 30}
for i, w := range wantNums {
if got[i].Number != w {
t.Errorf("dep[%d].Number = %d, want %d", i, got[i].Number, w)
}
}
}
// --- ListIssueComments pagination ------------------------------------------
func TestListIssueComments_Pagination(t *testing.T) {
page1 := make([]gitea.Comment, 50)
for i := range page1 {
page1[i] = gitea.Comment{ID: i + 1}
}
page2 := []gitea.Comment{{ID: 51}, {ID: 52}}
callCount := 0
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
json.NewEncoder(w).Encode(page1)
} else {
json.NewEncoder(w).Encode(page2)
}
}))
comments, err := c.ListIssueComments(context.Background(), "owner", "repo", 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(comments) != 52 {
t.Errorf("len = %d, want 52", len(comments))
}
}
// --- ListPullRequestFiles pagination ---------------------------------------
func TestListPullRequestFiles_Pagination(t *testing.T) {
page1 := make([]gitea.PullRequestFile, 50)
for i := range page1 {
page1[i] = gitea.PullRequestFile{Filename: fmt.Sprintf("file%d.go", i)}
}
page2 := []gitea.PullRequestFile{{Filename: "extra.go"}}
callCount := 0
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
json.NewEncoder(w).Encode(page1)
} else {
json.NewEncoder(w).Encode(page2)
}
}))
files, err := c.ListPullRequestFiles(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 51 {
t.Errorf("len = %d, want 51", len(files))
}
}
// --- ListPullRequestCommits ------------------------------------------------
func TestListPullRequestCommits(t *testing.T) {
var gotPath string
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `[
{"sha":"aaa111","commit":{"message":"first commit","author":{"name":"Alice"}}},
{"sha":"bbb222","commit":{"message":"second commit","author":{"name":"Bob"}}}
]`)
}))
commits, err := c.ListPullRequestCommits(context.Background(), "owner", "repo", 7)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := "/api/v1/repos/owner/repo/pulls/7/commits"; gotPath != want {
t.Errorf("path = %q, want %q", gotPath, want)
}
if len(commits) != 2 {
t.Fatalf("len = %d, want 2", len(commits))
}
if commits[0].SHA != "aaa111" {
t.Errorf("commits[0].SHA = %q, want aaa111", commits[0].SHA)
}
if commits[0].Commit.Message != "first commit" {
t.Errorf("commits[0].Commit.Message = %q, want %q", commits[0].Commit.Message, "first commit")
}
if commits[1].Commit.Author.Name != "Bob" {
t.Errorf("commits[1].Commit.Author.Name = %q, want Bob", commits[1].Commit.Author.Name)
}
}
func TestListPullRequestCommits_Pagination(t *testing.T) {
page1 := make([]gitea.PullRequestCommit, 50)
for i := range page1 {
page1[i].SHA = fmt.Sprintf("sha%d", i)
}
page2 := []gitea.PullRequestCommit{{SHA: "extra"}}
callCount := 0
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
json.NewEncoder(w).Encode(page1)
} else {
json.NewEncoder(w).Encode(page2)
}
}))
commits, err := c.ListPullRequestCommits(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(commits) != 51 {
t.Errorf("len = %d, want 51", len(commits))
}
}
// --- GetPullRequest --------------------------------------------------------
func TestGetPullRequest(t *testing.T) {
payload := map[string]any{
"number": 42,
"merged": true,
"state": "closed",
"merged_at": "2024-01-15T10:00:00Z",
"html_url": "https://gitea.example.com/owner/repo/pulls/42",
"body": "Closes #7",
"base": map[string]any{"ref": "release/v2"},
}
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}))
pr, err := c.GetPullRequest(context.Background(), "owner", "repo", 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pr.Number != 42 || !pr.Merged || pr.State != "closed" {
t.Errorf("pr = %+v", pr)
}
if pr.Body != "Closes #7" {
t.Errorf("pr.Body = %q", pr.Body)
}
if pr.Base.Ref != "release/v2" {
t.Errorf("pr.Base.Ref = %q, want %q", pr.Base.Ref, "release/v2")
}
}
// --- APIError.Error() contains status and URL ------------------------------
func TestAPIError_ErrorString(t *testing.T) {
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, "forbidden")
}))
_, err := c.GetCurrentUser(context.Background())
if err == nil {
t.Fatal("expected error")
}
s := err.Error()
if !strings.Contains(s, "403") {
t.Errorf("error string %q does not contain 403", s)
}
}
// --- URL path construction -------------------------------------------------
func TestGetIssue_URLPath(t *testing.T) {
var gotPath string
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(gitea.Issue{Number: 3})
}))
_, err := c.GetIssue(context.Background(), "myowner", "myrepo", 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "/api/v1/repos/myowner/myrepo/issues/3"
if gotPath != want {
t.Errorf("path = %q, want %q", gotPath, want)
}
}
// --- CloseIssue sends PATCH with state=closed body ----------------------------
func TestCloseIssue_MethodPathBody(t *testing.T) {
var gotMethod, gotPath string
var gotBody map[string]string
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("decode body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"number":5,"state":"closed"}`)
}))
if err := c.CloseIssue(context.Background(), "owner", "repo", 5); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotMethod != http.MethodPatch {
t.Errorf("method = %s, want PATCH", gotMethod)
}
if gotPath != "/api/v1/repos/owner/repo/issues/5" {
t.Errorf("path = %s, want /api/v1/repos/owner/repo/issues/5", gotPath)
}
if gotBody["state"] != "closed" {
t.Errorf("body state = %q, want closed", gotBody["state"])
}
}
func TestCloseIssue_APIError(t *testing.T) {
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprint(w, `{"message":"issue already closed"}`)
}))
err := c.CloseIssue(context.Background(), "owner", "repo", 5)
if err == nil {
t.Fatal("expected error, got nil")
}
var apiErr *gitea.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err type = %T, want *gitea.APIError", err)
}
if apiErr.Status != http.StatusUnprocessableEntity {
t.Errorf("Status = %d, want %d", apiErr.Status, http.StatusUnprocessableEntity)
}
if !strings.Contains(apiErr.Body, "already closed") {
t.Errorf("Body = %q, want 'already closed'", apiErr.Body)
}
}
// --- DeleteIssue sends DELETE on correct path --------------------------------
func TestDeleteIssue_MethodPath(t *testing.T) {
var gotMethod, gotPath string
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
if err := c.DeleteIssue(context.Background(), "owner", "repo", 7); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotMethod != http.MethodDelete {
t.Errorf("method = %s, want DELETE", gotMethod)
}
if gotPath != "/api/v1/repos/owner/repo/issues/7" {
t.Errorf("path = %s, want /api/v1/repos/owner/repo/issues/7", gotPath)
}
}
func TestDeleteIssue_APIError(t *testing.T) {
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"message":"only admins can delete issues"}`)
}))
err := c.DeleteIssue(context.Background(), "owner", "repo", 7)
if err == nil {
t.Fatal("expected error, got nil")
}
var apiErr *gitea.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err type = %T, want *gitea.APIError", err)
}
if apiErr.Status != http.StatusForbidden {
t.Errorf("Status = %d, want 403", apiErr.Status)
}
}
// --- AddDependency sends POST with correct path and body -------------------
func TestAddDependency_MethodPathBody(t *testing.T) {
var gotMethod, gotPath string
var gotBody map[string]int
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("decode body: %v", err)
}
w.WriteHeader(http.StatusCreated)
}))
if err := c.AddDependency(context.Background(), "owner", "repo", 5, 3); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %s, want POST", gotMethod)
}
if gotPath != "/api/v1/repos/owner/repo/issues/5/dependencies" {
t.Errorf("path = %s, want /api/v1/repos/owner/repo/issues/5/dependencies", gotPath)
}
if gotBody["index"] != 3 {
t.Errorf("body index = %d, want 3", gotBody["index"])
}
}
func TestAddDependency_APIError(t *testing.T) {
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprint(w, `{"message":"dependency cycle"}`)
}))
err := c.AddDependency(context.Background(), "owner", "repo", 5, 3)
if err == nil {
t.Fatal("expected error, got nil")
}
var apiErr *gitea.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err type = %T, want *gitea.APIError", err)
}
if apiErr.Status != http.StatusUnprocessableEntity {
t.Errorf("Status = %d, want 422", apiErr.Status)
}
if !strings.Contains(apiErr.Body, "cycle") {
t.Errorf("Body = %q, want 'cycle'", apiErr.Body)
}
}
// --- RemoveDependency sends DELETE with correct path and body ---------------
func TestRemoveDependency_MethodPathBody(t *testing.T) {
var gotMethod, gotPath string
var gotBody map[string]int
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("decode body: %v", err)
}
w.WriteHeader(http.StatusNoContent)
}))
if err := c.RemoveDependency(context.Background(), "owner", "repo", 5, 3); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotMethod != http.MethodDelete {
t.Errorf("method = %s, want DELETE", gotMethod)
}
if gotPath != "/api/v1/repos/owner/repo/issues/5/dependencies" {
t.Errorf("path = %s, want /api/v1/repos/owner/repo/issues/5/dependencies", gotPath)
}
if gotBody["index"] != 3 {
t.Errorf("body index = %d, want 3", gotBody["index"])
}
}
func TestRemoveDependency_APIError(t *testing.T) {
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"message":"dependency not found"}`)
}))
err := c.RemoveDependency(context.Background(), "owner", "repo", 5, 3)
if err == nil {
t.Fatal("expected error, got nil")
}
var apiErr *gitea.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err type = %T, want *gitea.APIError", err)
}
if apiErr.Status != http.StatusNotFound {
t.Errorf("Status = %d, want 404", apiErr.Status)
}
}
// Verify mustJSON doesn't appear in production (compile-time check via blank import).
var _ = mustJSON
// --- ClosePullRequest -------------------------------------------------------
func TestClosePullRequest_200(t *testing.T) {
var method, path string
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
path = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
err := c.ClosePullRequest(context.Background(), "owner", "repo", 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if method != http.MethodPatch {
t.Errorf("method = %q, want PATCH", method)
}
if path != "/api/v1/repos/owner/repo/issues/42" {
t.Errorf("path = %q, want /api/v1/repos/owner/repo/issues/42", path)
}
}
func TestClosePullRequest_422(t *testing.T) {
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unprocessable", http.StatusUnprocessableEntity)
}))
err := c.ClosePullRequest(context.Background(), "owner", "repo", 1)
if err == nil {
t.Fatal("expected error")
}
var apiErr *gitea.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err is %T, want *gitea.APIError", err)
}
if apiErr.Status != 422 {
t.Errorf("Status = %d, want 422", apiErr.Status)
}
}
// --- DeleteBranch -----------------------------------------------------------
func TestDeleteBranch_204(t *testing.T) {
var method, rawPath string
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
// Use RawPath when set (encoded), fall back to Path.
rawPath = r.URL.RawPath
if rawPath == "" {
rawPath = r.URL.Path
}
w.WriteHeader(http.StatusNoContent)
}))
err := c.DeleteBranch(context.Background(), "owner", "repo", "feature/my-branch")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if method != http.MethodDelete {
t.Errorf("method = %q, want DELETE", method)
}
if rawPath != "/api/v1/repos/owner/repo/branches/feature%2Fmy-branch" {
t.Errorf("rawPath = %q, want /api/v1/repos/owner/repo/branches/feature%%2Fmy-branch", rawPath)
}
}
func TestDeleteBranch_404(t *testing.T) {
_, c := newServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
err := c.DeleteBranch(context.Background(), "owner", "repo", "gone")
if err == nil {
t.Fatal("expected error on 404")
}
var apiErr *gitea.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err is %T, want *gitea.APIError", err)
}
if apiErr.Status != 404 {
t.Errorf("Status = %d, want 404", apiErr.Status)
}
}