d65676afe6
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>
955 lines
27 KiB
Go
955 lines
27 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewClient(t *testing.T) {
|
|
c := NewClient("https://gitea.example.com")
|
|
if c.baseURL != "https://gitea.example.com" {
|
|
t.Errorf("baseURL = %q, want %q", c.baseURL, "https://gitea.example.com")
|
|
}
|
|
if c.maxConcurrent != 5 {
|
|
t.Errorf("maxConcurrent = %d, want 5", c.maxConcurrent)
|
|
}
|
|
if c.cacheTTL != 30*time.Second {
|
|
t.Errorf("cacheTTL = %v, want 30s", c.cacheTTL)
|
|
}
|
|
}
|
|
|
|
func TestNewClient_TrailingSlash(t *testing.T) {
|
|
c := NewClient("https://gitea.example.com/")
|
|
if c.baseURL != "https://gitea.example.com" {
|
|
t.Errorf("baseURL = %q, want trailing slash removed", c.baseURL)
|
|
}
|
|
}
|
|
|
|
func TestCache(t *testing.T) {
|
|
c := NewClient("https://gitea.example.com")
|
|
|
|
// Cache miss.
|
|
_, ok := c.getFromCache("key1")
|
|
if ok {
|
|
t.Error("expected cache miss")
|
|
}
|
|
|
|
// Cache set and hit.
|
|
c.setCache("key1", "value1")
|
|
val, ok := c.getFromCache("key1")
|
|
if !ok {
|
|
t.Fatal("expected cache hit")
|
|
}
|
|
if val.(string) != "value1" {
|
|
t.Errorf("got %q, want %q", val, "value1")
|
|
}
|
|
|
|
// Invalidate.
|
|
c.invalidateCache("key")
|
|
_, ok = c.getFromCache("key1")
|
|
if ok {
|
|
t.Error("expected cache miss after invalidation")
|
|
}
|
|
}
|
|
|
|
func TestCacheExpiry(t *testing.T) {
|
|
c := NewClient("https://gitea.example.com")
|
|
c.cacheTTL = 1 * time.Millisecond
|
|
|
|
c.setCache("key1", "value1")
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
_, ok := c.getFromCache("key1")
|
|
if ok {
|
|
t.Error("expected cache miss after TTL expiry")
|
|
}
|
|
}
|
|
|
|
func TestInvalidateAll(t *testing.T) {
|
|
c := NewClient("https://gitea.example.com")
|
|
c.setCache("key1", "value1")
|
|
c.setCache("key2", "value2")
|
|
|
|
c.InvalidateAll()
|
|
|
|
_, ok1 := c.getFromCache("key1")
|
|
_, ok2 := c.getFromCache("key2")
|
|
if ok1 || ok2 {
|
|
t.Error("expected all cache entries to be invalidated")
|
|
}
|
|
}
|
|
|
|
func TestPriorityScore(t *testing.T) {
|
|
tests := []struct {
|
|
labels []string
|
|
want int
|
|
}{
|
|
{[]string{"P1", "bug"}, 1},
|
|
{[]string{"P2"}, 2},
|
|
{[]string{"P3", "enhancement"}, 3},
|
|
{[]string{"bug", "enhancement"}, 4},
|
|
{nil, 4},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := priorityScore(tt.labels)
|
|
if got != tt.want {
|
|
t.Errorf("priorityScore(%v) = %d, want %d", tt.labels, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListOrgs(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/user/orgs" {
|
|
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 Authorization header")
|
|
}
|
|
|
|
orgs := []Org{
|
|
{Name: "org1", FullName: "Organization 1"},
|
|
{Name: "org2", FullName: "Organization 2"},
|
|
}
|
|
json.NewEncoder(w).Encode(orgs)
|
|
}))
|
|
defer server.Close()
|
|
|
|
c := NewClient(server.URL)
|
|
orgs, err := c.ListOrgs(context.Background(), "test-token")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(orgs) != 2 {
|
|
t.Fatalf("got %d orgs, want 2", len(orgs))
|
|
}
|
|
if orgs[0].Name != "org1" {
|
|
t.Errorf("orgs[0].Name = %q, want %q", orgs[0].Name, "org1")
|
|
}
|
|
}
|
|
|
|
func TestListOrgs_Cached(t *testing.T) {
|
|
callCount := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
callCount++
|
|
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
|
|
}))
|
|
defer server.Close()
|
|
|
|
c := NewClient(server.URL)
|
|
|
|
// First call should hit the server.
|
|
_, err := c.ListOrgs(context.Background(), "test-token")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Second call should use cache.
|
|
_, err = c.ListOrgs(context.Background(), "test-token")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if callCount != 1 {
|
|
t.Errorf("server called %d times, want 1 (cached)", callCount)
|
|
}
|
|
}
|
|
|
|
func TestListOrgRepos(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
repos := []Repo{
|
|
{ID: 1, Name: "repo1", FullName: "org1/repo1"},
|
|
{ID: 2, Name: "repo2", FullName: "org1/repo2"},
|
|
}
|
|
json.NewEncoder(w).Encode(repos)
|
|
}))
|
|
defer server.Close()
|
|
|
|
c := NewClient(server.URL)
|
|
repos, err := c.ListOrgRepos(context.Background(), "test-token", "org1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(repos) != 2 {
|
|
t.Fatalf("got %d repos, want 2", len(repos))
|
|
}
|
|
}
|
|
|
|
func TestGetTriageQueue_Sorting(t *testing.T) {
|
|
queue := []TriageItem{
|
|
{Title: "low", Labels: []string{"P3"}, UpdatedAt: time.Now()},
|
|
{Title: "high", Labels: []string{"P1"}, UpdatedAt: time.Now()},
|
|
{Title: "medium", Labels: []string{"P2"}, UpdatedAt: time.Now()},
|
|
{Title: "none", Labels: nil, UpdatedAt: time.Now()},
|
|
}
|
|
|
|
// Apply the same sort as GetTriageQueue.
|
|
sortTriageQueue(queue)
|
|
|
|
expected := []string{"high", "medium", "low", "none"}
|
|
for i, item := range queue {
|
|
if item.Title != expected[i] {
|
|
t.Errorf("queue[%d].Title = %q, want %q", i, item.Title, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCloseIssue(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/owner1/repo1/issues/42" {
|
|
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]string
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("failed to decode body: %v", err)
|
|
}
|
|
if body["state"] != "closed" {
|
|
t.Errorf("expected state=closed, got %q", body["state"])
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"state": "closed"})
|
|
}))
|
|
defer server.Close()
|
|
|
|
c := NewClient(server.URL)
|
|
c.setCache("issues-org1", "should-be-invalidated")
|
|
|
|
err := c.CloseIssue(context.Background(), "test-token", "owner1", "repo1", 42)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Verify cache was invalidated.
|
|
_, ok := c.getFromCache("issues-org1")
|
|
if ok {
|
|
t.Error("expected cache to be invalidated after CloseIssue")
|
|
}
|
|
}
|
|
|
|
func TestSetIssueState(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
state string
|
|
}{
|
|
{"close", "closed"},
|
|
{"reopen", "open"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(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/owner1/repo1/issues/42" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
|
|
var body map[string]string
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("failed to decode body: %v", err)
|
|
}
|
|
if body["state"] != tt.state {
|
|
t.Errorf("expected state=%q, got %q", tt.state, body["state"])
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"state": tt.state})
|
|
}))
|
|
defer server.Close()
|
|
|
|
c := NewClient(server.URL)
|
|
c.setCache("issues-org1", "should-be-invalidated")
|
|
|
|
err := c.SetIssueState(context.Background(), "test-token", "owner1", "repo1", 42, tt.state)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
_, ok := c.getFromCache("issues-org1")
|
|
if ok {
|
|
t.Error("expected cache to be invalidated after SetIssueState")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAddComment(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
comment := map[string]interface{}{
|
|
"id": 1,
|
|
"body": "test",
|
|
"user": map[string]string{"login": "testuser"},
|
|
"created_at": "2026-03-26T12:00:00Z",
|
|
}
|
|
json.NewEncoder(w).Encode(comment)
|
|
}))
|
|
defer server.Close()
|
|
|
|
c := NewClient(server.URL)
|
|
comment, err := c.AddComment(context.Background(), "test-token", "owner1", "repo1", 42, "test")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if comment.Body != "test" {
|
|
t.Errorf("comment.Body = %q, want %q", comment.Body, "test")
|
|
}
|
|
}
|
|
|
|
func TestPostComment(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/42/comments" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
|
|
var body map[string]string
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatalf("failed to decode body: %v", err)
|
|
}
|
|
if body["body"] != "test comment" {
|
|
t.Errorf("expected body='test comment', got %q", body["body"])
|
|
}
|
|
|
|
comment := map[string]interface{}{
|
|
"id": 1,
|
|
"body": body["body"],
|
|
"user": map[string]string{"login": "testuser"},
|
|
"created_at": "2026-03-26T12:00:00Z",
|
|
}
|
|
json.NewEncoder(w).Encode(comment)
|
|
}))
|
|
defer server.Close()
|
|
|
|
c := NewClient(server.URL)
|
|
c.setCache("issues-org1", "should-be-invalidated")
|
|
|
|
comment, err := c.PostComment(context.Background(), "test-token", "owner1", "repo1", 42, "test comment")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if comment.Body != "test comment" {
|
|
t.Errorf("comment.Body = %q, want %q", comment.Body, "test comment")
|
|
}
|
|
if comment.User != "testuser" {
|
|
t.Errorf("comment.User = %q, want %q", comment.User, "testuser")
|
|
}
|
|
if comment.ID != 1 {
|
|
t.Errorf("comment.ID = %d, want 1", comment.ID)
|
|
}
|
|
|
|
// Verify cache was invalidated.
|
|
_, ok := c.getFromCache("issues-org1")
|
|
if ok {
|
|
t.Error("expected cache to be invalidated after PostComment")
|
|
}
|
|
}
|
|
|
|
// sortTriageQueue is a test helper applying the same sort as GetTriageQueue.
|
|
func sortTriageQueue(queue []TriageItem) {
|
|
for i := 0; i < len(queue); i++ {
|
|
for j := i + 1; j < len(queue); j++ {
|
|
pi := priorityScore(queue[i].Labels)
|
|
pj := priorityScore(queue[j].Labels)
|
|
if pj < pi || (pj == pi && queue[j].UpdatedAt.After(queue[i].UpdatedAt)) {
|
|
queue[i], queue[j] = queue[j], queue[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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")
|
|
}
|
|
}
|