go-scm/forge/testhelper_test.go
Claude 9db37c6fb3
test: add comprehensive unit tests for forge/, gitea/, git/, agentci/
Phase 1 test coverage for the three 0% packages plus agentci/ improvement:

- git/ (0% -> 79.5%): RepoStatus methods, status parsing with real temp
  repos, multi-repo parallel status, Push/Pull error paths, ahead/behind
  with bare remote, context cancellation, GitError, IsNonFastForward,
  service DirtyRepos/AheadRepos filtering

- forge/ (0% -> 91.2%): All SDK wrapper functions tested via httptest mock
  server — client creation, repos, issues, PRs, labels, webhooks, orgs,
  meta, config resolution, SetPRDraft raw HTTP endpoint

- gitea/ (0% -> 89.2%): All SDK wrapper functions tested via httptest mock
  server — client creation, repos, issues, PRs, meta, config resolution

- agentci/ (56% -> 94.5%): Clotho DeterminePlan all code paths, security
  helpers (SanitizePath, EscapeShellArg, SecureSSHCommand, MaskToken)

Key findings documented in FINDINGS.md:
- Forgejo SDK validates token via HTTP on NewClient()
- SDK route patterns differ from public API docs (/org/ vs /orgs/)
- Gitea SDK requires auth token for GitHub mirror creation
- Config resolution priority verified: config file < env vars < flags

Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 00:59:46 +00:00

352 lines
11 KiB
Go

package forge
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newMockForgejoServer creates an httptest.Server that mimics the Forgejo API
// endpoints used during client initialisation and common operations.
func newMockForgejoServer(t *testing.T) *httptest.Server {
t.Helper()
mux := newForgejoMux()
return httptest.NewServer(mux)
}
// newForgejoMux creates an http.ServeMux with standard Forgejo API responses.
func newForgejoMux() *http.ServeMux {
mux := http.NewServeMux()
// The Forgejo SDK calls /api/v1/version during NewClient().
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, map[string]string{"version": "1.21.0"})
})
// User info endpoint for GetCurrentUser / GetMyUserInfo.
mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, map[string]any{
"id": 1,
"login": "test-user",
"full_name": "Test User",
"email": "test@example.com",
"login_name": "test-user",
})
})
// Repos listing (user).
mux.HandleFunc("/api/v1/user/repos", func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, []map[string]any{
{"id": 1, "name": "repo-a", "full_name": "test-user/repo-a", "owner": map[string]any{"login": "test-user"}},
{"id": 2, "name": "repo-b", "full_name": "test-user/repo-b", "owner": map[string]any{"login": "test-user"}},
})
})
// Org repos listing + create.
mux.HandleFunc("/api/v1/orgs/test-org/repos", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
jsonResponse(w, map[string]any{
"id": 20, "name": "new-repo", "full_name": "test-org/new-repo",
"owner": map[string]any{"login": "test-org"},
})
return
}
jsonResponse(w, []map[string]any{
{"id": 10, "name": "org-repo", "full_name": "test-org/org-repo", "owner": map[string]any{"login": "test-org", "id": 100}},
})
})
// Create org repo (SDK uses /org/ not /orgs/).
mux.HandleFunc("/api/v1/org/test-org/repos", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
jsonResponse(w, map[string]any{
"id": 20, "name": "new-repo", "full_name": "test-org/new-repo",
"owner": map[string]any{"login": "test-org"},
})
return
}
jsonResponse(w, []map[string]any{})
})
// Get/delete single repo.
mux.HandleFunc("/api/v1/repos/test-org/org-repo", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodDelete {
w.WriteHeader(http.StatusNoContent)
return
}
jsonResponse(w, map[string]any{
"id": 10, "name": "org-repo", "full_name": "test-org/org-repo",
"owner": map[string]any{"login": "test-org"},
})
})
// Issues.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/issues", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
jsonResponse(w, map[string]any{
"id": 1, "number": 1, "title": "Test Issue", "state": "open",
"body": "Issue body text",
})
return
}
jsonResponse(w, []map[string]any{
{"id": 1, "number": 1, "title": "Issue 1", "state": "open", "body": "First issue"},
{"id": 2, "number": 2, "title": "Issue 2", "state": "closed", "body": "Second issue"},
})
})
// Single issue.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/issues/1", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch {
jsonResponse(w, map[string]any{
"id": 1, "number": 1, "title": "Issue 1", "state": "open",
"body": "First issue",
})
return
}
jsonResponse(w, map[string]any{
"id": 1, "number": 1, "title": "Issue 1", "state": "open",
"body": "First issue body",
})
})
// Issue comments.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/issues/1/comments", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
jsonResponse(w, map[string]any{
"id": 100, "body": "test comment",
"user": map[string]any{"login": "test-user"},
})
return
}
jsonResponse(w, []map[string]any{
{"id": 100, "body": "comment 1", "user": map[string]any{"login": "user1"}, "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"},
{"id": 101, "body": "comment 2", "user": map[string]any{"login": "user2"}, "created_at": "2026-01-02T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z"},
})
})
// Issue labels.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/issues/1/labels", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
jsonResponse(w, []map[string]any{
{"id": 1, "name": "bug", "color": "#ff0000"},
})
return
}
jsonResponse(w, []map[string]any{
{"id": 1, "name": "bug", "color": "#ff0000"},
})
})
// Remove issue label.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/issues/1/labels/1", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodDelete {
w.WriteHeader(http.StatusNoContent)
return
}
})
// Pull requests.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
jsonResponse(w, map[string]any{
"id": 1, "number": 1, "title": "Test PR", "state": "open",
"head": map[string]any{"ref": "feature", "label": "feature"},
"base": map[string]any{"ref": "main", "label": "main"},
})
return
}
jsonResponse(w, []map[string]any{
{
"id": 1, "number": 1, "title": "PR 1", "state": "open",
"head": map[string]any{"ref": "feature", "label": "feature"},
"base": map[string]any{"ref": "main", "label": "main"},
},
})
})
// Single pull request.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls/1", func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, map[string]any{
"id": 1, "number": 1, "title": "PR 1", "state": "open",
"merged": false,
"head": map[string]any{"ref": "feature", "label": "feature"},
"base": map[string]any{"ref": "main", "label": "main"},
"user": map[string]any{"login": "author"},
"labels": []map[string]any{{"name": "enhancement"}},
"assignees": []map[string]any{
{"login": "dev1"},
},
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-16T12:00:00Z",
})
})
// PR merge.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls/1/merge", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// PR reviews.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls/1/reviews", func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, []map[string]any{
{"id": 1, "state": "APPROVED", "user": map[string]any{"login": "reviewer1"}},
})
})
// Combined status.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/commits/main/status", func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, map[string]any{
"state": "success",
"statuses": []map[string]any{
{"context": "ci/build", "state": "success"},
},
})
})
// Repo labels.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/labels", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
jsonResponse(w, map[string]any{
"id": 5, "name": "new-label", "color": "#00ff00",
})
return
}
jsonResponse(w, []map[string]any{
{"id": 1, "name": "bug", "color": "#ff0000"},
{"id": 2, "name": "feature", "color": "#0000ff"},
})
})
// Webhooks.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/hooks", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
jsonResponse(w, map[string]any{
"id": 1,
"type": "forgejo",
"active": true,
"config": map[string]any{"url": "https://example.com/hook"},
})
return
}
jsonResponse(w, []map[string]any{
{"id": 1, "type": "forgejo", "active": true, "config": map[string]any{"url": "https://example.com/hook"}},
})
})
// Orgs listing.
mux.HandleFunc("/api/v1/user/orgs", func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, []map[string]any{
{"id": 100, "login": "test-org", "username": "test-org", "full_name": "Test Organisation"},
})
})
// Single org.
mux.HandleFunc("/api/v1/orgs/test-org", func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, map[string]any{
"id": 100, "login": "test-org", "username": "test-org", "full_name": "Test Organisation",
})
})
// Create org.
mux.HandleFunc("/api/v1/orgs", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
jsonResponse(w, map[string]any{
"id": 200, "login": "new-org", "username": "new-org", "full_name": "New Organisation",
})
return
}
})
// Fork repo.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/forks", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
jsonResponse(w, map[string]any{
"id": 30, "name": "org-repo", "full_name": "test-user/org-repo",
"owner": map[string]any{"login": "test-user"},
})
})
// Migrate repo.
mux.HandleFunc("/api/v1/repos/migrate", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
jsonResponse(w, map[string]any{
"id": 40, "name": "migrated-repo", "full_name": "test-user/migrated-repo",
"owner": map[string]any{"login": "test-user"},
})
})
// Dismiss review.
mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls/1/reviews/1/dismissals", func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, map[string]any{
"id": 1, "state": "dismissed",
})
})
// Generic fallback — handles PATCH for SetPRDraft and other unmatched routes.
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Handle PATCH requests (SetPRDraft).
if r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/pulls/") {
jsonResponse(w, map[string]any{
"number": 1, "title": "test PR", "state": "open",
})
return
}
http.NotFound(w, r)
})
return mux
}
// jsonResponse writes a JSON response with 200 status (unless already set).
func jsonResponse(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(data)
}
// newTestClient creates a Client backed by the mock server.
func newTestClient(t *testing.T) (*Client, *httptest.Server) {
t.Helper()
srv := newMockForgejoServer(t)
client, err := New(srv.URL, "test-token")
if err != nil {
srv.Close()
t.Fatalf("failed to create test client: %v", err)
}
return client, srv
}
// newErrorServer creates a mock server that returns errors for all API calls
// (except /api/v1/version which is needed for client creation).
func newErrorServer(t *testing.T) (*Client, *httptest.Server) {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, map[string]string{"version": "1.21.0"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
})
srv := httptest.NewServer(mux)
client, err := New(srv.URL, "token")
if err != nil {
srv.Close()
t.Fatalf("failed to create error server client: %v", err)
}
return client, srv
}