added0778e
Add CloseIssue (PATCH state=closed) and PostComment (POST comment body)
methods to the Gitea client with cache invalidation. Add corresponding
handler routes POST /issues/{owner}/{repo}/{index}/close and
POST /issues/{owner}/{repo}/{index}/comment with HTMX support.
Include unit tests for both client methods.
Closes leeworks-agents/gitea-mobile#36
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
307 lines
7.9 KiB
Go
307 lines
7.9 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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 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]
|
|
}
|
|
}
|
|
}
|
|
}
|