diff --git a/internal/gitea/client_test.go b/internal/gitea/client_test.go index 5c839ed..a54f320 100644 --- a/internal/gitea/client_test.go +++ b/internal/gitea/client_test.go @@ -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") + } +}