test: add unit tests for ListOrgsAndRepos, CreateIssue, ListAllIssues, ListAllPullRequests

Add comprehensive unit tests using mock HTTP servers for four key
aggregation methods in the Gitea client. Tests cover correct API
integration, caching behavior, sorting, state filtering, repo
filtering, pagination, and label handling.

Closes leeworks-agents/gitea-mobile#122
Closes leeworks-agents/gitea-mobile#121

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent-company
2026-03-28 11:05:34 +00:00
parent a0f786e894
commit d65676afe6
+578
View File
@@ -3,6 +3,7 @@ package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -374,3 +375,580 @@ func sortTriageQueue(queue []TriageItem) {
}
}
}
// --- Issue #122: Tests for ListOrgsAndRepos and CreateIssue ---
func TestListOrgsAndRepos(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/user/orgs":
orgs := []Org{
{Name: "org1", FullName: "Organization 1"},
{Name: "org2", FullName: "Organization 2"},
}
json.NewEncoder(w).Encode(orgs)
case "/api/v1/orgs/org1/repos":
repos := []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a"},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b"},
}
json.NewEncoder(w).Encode(repos)
case "/api/v1/orgs/org2/repos":
repos := []Repo{
{ID: 3, Name: "repo-c", FullName: "org2/repo-c"},
}
json.NewEncoder(w).Encode(repos)
default:
t.Errorf("unexpected request path: %s", r.URL.Path)
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
result, err := c.ListOrgsAndRepos(context.Background(), "test-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("got %d orgs, want 2", len(result))
}
if len(result["org1"]) != 2 {
t.Errorf("org1 has %d repos, want 2", len(result["org1"]))
}
if len(result["org2"]) != 1 {
t.Errorf("org2 has %d repos, want 1", len(result["org2"]))
}
if result["org1"][0].Name != "repo-a" {
t.Errorf("org1 repos[0].Name = %q, want %q", result["org1"][0].Name, "repo-a")
}
if result["org2"][0].Name != "repo-c" {
t.Errorf("org2 repos[0].Name = %q, want %q", result["org2"][0].Name, "repo-c")
}
}
func TestListOrgsAndRepos_Cached(t *testing.T) {
orgCallCount := 0
repoCallCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/user/orgs":
orgCallCount++
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case "/api/v1/orgs/org1/repos":
repoCallCount++
json.NewEncoder(w).Encode([]Repo{{ID: 1, Name: "repo1", FullName: "org1/repo1"}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
// First call populates cache.
_, err := c.ListOrgsAndRepos(context.Background(), "test-token")
if err != nil {
t.Fatalf("first call: %v", err)
}
// Second call should use cached orgs and repos (ListOrgs and ListOrgRepos both cache).
_, err = c.ListOrgsAndRepos(context.Background(), "test-token")
if err != nil {
t.Fatalf("second call: %v", err)
}
if orgCallCount != 1 {
t.Errorf("org endpoint called %d times, want 1 (cached)", orgCallCount)
}
if repoCallCount != 1 {
t.Errorf("repo endpoint called %d times, want 1 (cached)", repoCallCount)
}
}
func TestCreateIssue(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 r.URL.Path != "/api/v1/repos/owner1/repo1/issues" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "token test-token" {
t.Error("missing or wrong Authorization header")
}
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if body["title"] != "Test Issue" {
t.Errorf("expected title='Test Issue', got %q", body["title"])
}
if body["body"] != "Issue body here" {
t.Errorf("expected body='Issue body here', got %q", body["body"])
}
issue := map[string]interface{}{
"id": 1,
"number": 42,
"title": body["title"],
"body": body["body"],
"state": "open",
"created_at": "2026-03-28T00:00:00Z",
"updated_at": "2026-03-28T00:00:00Z",
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(issue)
}))
defer server.Close()
c := NewClient(server.URL)
// Pre-populate cache to verify invalidation.
c.setCache("issues-org1", "should-be-invalidated")
issue, err := c.CreateIssue(context.Background(), "test-token", "owner1", "repo1", "Test Issue", "Issue body here", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if issue.Title != "Test Issue" {
t.Errorf("issue.Title = %q, want %q", issue.Title, "Test Issue")
}
if issue.Number != 42 {
t.Errorf("issue.Number = %d, want 42", issue.Number)
}
if issue.RepoOwner != "owner1" {
t.Errorf("issue.RepoOwner = %q, want %q", issue.RepoOwner, "owner1")
}
if issue.RepoName != "repo1" {
t.Errorf("issue.RepoName = %q, want %q", issue.RepoName, "repo1")
}
// Verify cache was invalidated.
_, ok := c.getFromCache("issues-org1")
if ok {
t.Error("expected cache to be invalidated after CreateIssue")
}
}
func TestCreateIssue_WithLabels(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
labels, ok := body["labels"]
if !ok {
t.Error("expected labels in request body")
}
labelSlice, ok := labels.([]interface{})
if !ok || len(labelSlice) != 2 {
t.Errorf("expected 2 labels, got %v", labels)
}
issue := map[string]interface{}{
"id": 2,
"number": 43,
"title": body["title"],
"body": body["body"],
"state": "open",
"created_at": "2026-03-28T00:00:00Z",
"updated_at": "2026-03-28T00:00:00Z",
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(issue)
}))
defer server.Close()
c := NewClient(server.URL)
issue, err := c.CreateIssue(context.Background(), "test-token", "owner1", "repo1", "Labeled Issue", "body", []int64{10, 20})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if issue.Number != 43 {
t.Errorf("issue.Number = %d, want 43", issue.Number)
}
}
// --- Issue #121: Tests for ListAllIssues and ListAllPullRequests ---
// newFanOutServer creates a mock HTTP server that serves orgs, repos, issues, and PRs
// for testing the fan-out aggregation functions.
func newFanOutServer(t *testing.T) *httptest.Server {
t.Helper()
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
repos := []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
}
json.NewEncoder(w).Encode(repos)
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
issues := []Issue{
{ID: 1, Number: 1, Title: "Issue A1", State: "open", UpdatedAt: now.Add(-1 * time.Hour)},
{ID: 2, Number: 2, Title: "Issue A2", State: "open", UpdatedAt: now.Add(-3 * time.Hour)},
}
json.NewEncoder(w).Encode(issues)
case r.URL.Path == "/api/v1/repos/org1/repo-b/issues":
issues := []Issue{
{ID: 3, Number: 1, Title: "Issue B1", State: "open", UpdatedAt: now.Add(-2 * time.Hour)},
}
json.NewEncoder(w).Encode(issues)
case r.URL.Path == "/api/v1/repos/org1/repo-a/pulls":
prs := []PullRequest{
{ID: 10, Number: 5, Title: "PR A1", State: "open", UpdatedAt: now.Add(-30 * time.Minute)},
}
json.NewEncoder(w).Encode(prs)
case r.URL.Path == "/api/v1/repos/org1/repo-b/pulls":
prs := []PullRequest{
{ID: 11, Number: 6, Title: "PR B1", State: "open", UpdatedAt: now.Add(-10 * time.Minute)},
{ID: 12, Number: 7, Title: "PR B2", State: "open", UpdatedAt: now.Add(-1 * time.Hour)},
}
json.NewEncoder(w).Encode(prs)
default:
t.Errorf("unexpected request path: %s", r.URL.Path)
http.NotFound(w, r)
}
}))
}
func TestListAllIssues_Sorting(t *testing.T) {
server := newFanOutServer(t)
defer server.Close()
c := NewClient(server.URL)
// Pre-populate org repos cache to avoid needing the /user/orgs endpoint.
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
result, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Issues) != 3 {
t.Fatalf("got %d issues, want 3", len(result.Issues))
}
// Should be sorted by UpdatedAt descending (newest first).
// Issue A1 (-1h), Issue B1 (-2h), Issue A2 (-3h).
expected := []string{"Issue A1", "Issue B1", "Issue A2"}
for i, title := range expected {
if result.Issues[i].Title != title {
t.Errorf("issues[%d].Title = %q, want %q", i, result.Issues[i].Title, title)
}
}
}
func TestListAllIssues_StateFilter(t *testing.T) {
stateReceived := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
repos := []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
}
json.NewEncoder(w).Encode(repos)
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
stateReceived = r.URL.Query().Get("state")
json.NewEncoder(w).Encode([]Issue{})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
_, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "closed", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stateReceived != "closed" {
t.Errorf("state query param = %q, want %q", stateReceived, "closed")
}
}
func TestListAllIssues_DefaultState(t *testing.T) {
stateReceived := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
stateReceived = r.URL.Query().Get("state")
json.NewEncoder(w).Encode([]Issue{})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
_, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stateReceived != "open" {
t.Errorf("default state = %q, want %q", stateReceived, "open")
}
}
func TestListAllIssues_RepoFilter(t *testing.T) {
server := newFanOutServer(t)
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
result, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "repo-a")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Only issues from repo-a should be returned.
if len(result.Issues) != 2 {
t.Fatalf("got %d issues, want 2 (only from repo-a)", len(result.Issues))
}
for _, issue := range result.Issues {
if issue.RepoName != "repo-a" {
t.Errorf("issue %q has RepoName=%q, want repo-a", issue.Title, issue.RepoName)
}
}
}
func TestListAllIssues_Pagination(t *testing.T) {
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
// Create enough issues to test pagination (PageSize = 20).
var issues []Issue
for i := 0; i < 25; i++ {
issues = append(issues, Issue{
ID: int64(i + 1),
Number: int64(i + 1),
Title: fmt.Sprintf("Issue %d", i+1),
State: "open",
UpdatedAt: now.Add(time.Duration(-i) * time.Hour),
})
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
json.NewEncoder(w).Encode(issues)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
// Page 1: should have 20 items with HasMore=true.
page1, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("page 1: %v", err)
}
if len(page1.Issues) != 20 {
t.Errorf("page 1: got %d issues, want 20", len(page1.Issues))
}
if !page1.HasMore {
t.Error("page 1: HasMore should be true")
}
// Page 2: should have 5 items with HasMore=false.
page2, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 2, "", "")
if err != nil {
t.Fatalf("page 2: %v", err)
}
if len(page2.Issues) != 5 {
t.Errorf("page 2: got %d issues, want 5", len(page2.Issues))
}
if page2.HasMore {
t.Error("page 2: HasMore should be false")
}
}
func TestListAllPullRequests_Sorting(t *testing.T) {
server := newFanOutServer(t)
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
result, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Pulls) != 3 {
t.Fatalf("got %d PRs, want 3", len(result.Pulls))
}
// Should be sorted by UpdatedAt descending.
// PR B1 (-10m), PR A1 (-30m), PR B2 (-1h).
expected := []string{"PR B1", "PR A1", "PR B2"}
for i, title := range expected {
if result.Pulls[i].Title != title {
t.Errorf("pulls[%d].Title = %q, want %q", i, result.Pulls[i].Title, title)
}
}
}
func TestListAllPullRequests_StateFilter(t *testing.T) {
stateReceived := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/pulls":
stateReceived = r.URL.Query().Get("state")
json.NewEncoder(w).Encode([]PullRequest{})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
_, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "closed", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stateReceived != "closed" {
t.Errorf("state query param = %q, want %q", stateReceived, "closed")
}
}
func TestListAllPullRequests_Pagination(t *testing.T) {
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
var prs []PullRequest
for i := 0; i < 25; i++ {
prs = append(prs, PullRequest{
ID: int64(i + 1),
Number: int64(i + 1),
Title: fmt.Sprintf("PR %d", i+1),
State: "open",
UpdatedAt: now.Add(time.Duration(-i) * time.Hour),
})
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/pulls":
json.NewEncoder(w).Encode(prs)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
page1, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("page 1: %v", err)
}
if len(page1.Pulls) != 20 {
t.Errorf("page 1: got %d PRs, want 20", len(page1.Pulls))
}
if !page1.HasMore {
t.Error("page 1: HasMore should be true")
}
page2, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "open", 2, "", "")
if err != nil {
t.Fatalf("page 2: %v", err)
}
if len(page2.Pulls) != 5 {
t.Errorf("page 2: got %d PRs, want 5", len(page2.Pulls))
}
if page2.HasMore {
t.Error("page 2: HasMore should be false")
}
}