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