go-scm/forge/client_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

478 lines
14 KiB
Go

package forge
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew_Good(t *testing.T) {
srv := newMockForgejoServer(t)
defer srv.Close()
client, err := New(srv.URL, "test-token-123")
require.NoError(t, err)
assert.NotNil(t, client)
assert.NotNil(t, client.API())
assert.Equal(t, srv.URL, client.URL())
assert.Equal(t, "test-token-123", client.Token())
}
func TestNew_Bad_InvalidURL(t *testing.T) {
// The Forgejo SDK may reject certain URL formats.
_, err := New("://invalid-url", "token")
assert.Error(t, err)
}
func TestClient_API_Good(t *testing.T) {
client, srv := newTestClient(t)
defer srv.Close()
assert.NotNil(t, client.API(), "API() should return the underlying SDK client")
}
func TestClient_URL_Good(t *testing.T) {
client, srv := newTestClient(t)
defer srv.Close()
assert.Equal(t, srv.URL, client.URL())
}
func TestClient_Token_Good(t *testing.T) {
client, srv := newTestClient(t)
defer srv.Close()
assert.Equal(t, "test-token", client.Token())
}
func TestClient_GetCurrentUser_Good(t *testing.T) {
client, srv := newTestClient(t)
defer srv.Close()
user, err := client.GetCurrentUser()
require.NoError(t, err)
assert.Equal(t, "test-user", user.UserName)
}
func TestClient_GetCurrentUser_Bad_ServerError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "1.21.0"})
})
mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client, err := New(srv.URL, "token")
require.NoError(t, err)
_, err = client.GetCurrentUser()
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get current user")
}
func TestClient_SetPRDraft_Good(t *testing.T) {
client, srv := newTestClient(t)
defer srv.Close()
err := client.SetPRDraft("owner", "repo", 1, true)
require.NoError(t, err)
}
func TestClient_SetPRDraft_Good_Undraft(t *testing.T) {
client, srv := newTestClient(t)
defer srv.Close()
err := client.SetPRDraft("owner", "repo", 1, false)
require.NoError(t, err)
}
func TestClient_SetPRDraft_Bad_ServerError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "1.21.0"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.NotFound(w, r)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client, err := New(srv.URL, "token")
require.NoError(t, err)
err = client.SetPRDraft("owner", "repo", 1, true)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected status 403")
}
func TestClient_SetPRDraft_Bad_ConnectionRefused(t *testing.T) {
// Use a closed server to simulate connection errors.
srv := newMockForgejoServer(t)
client, err := New(srv.URL, "token")
require.NoError(t, err)
srv.Close() // Close the server.
err = client.SetPRDraft("owner", "repo", 1, true)
assert.Error(t, err)
}
func TestClient_SetPRDraft_URLConstruction(t *testing.T) {
// Verify the URL is constructed correctly by checking the request path.
var capturedPath string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "1.21.0"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{"number": 42})
})
srv := httptest.NewServer(mux)
defer srv.Close()
client, err := New(srv.URL, "token")
require.NoError(t, err)
_ = client.SetPRDraft("my-org", "my-repo", 42, true)
assert.Equal(t, "/api/v1/repos/my-org/my-repo/pulls/42", capturedPath)
}
func TestClient_SetPRDraft_AuthHeader(t *testing.T) {
// Verify the authorisation header is set correctly.
var capturedAuth string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "1.21.0"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{"number": 1})
})
srv := httptest.NewServer(mux)
defer srv.Close()
client, err := New(srv.URL, "my-secret-token")
require.NoError(t, err)
_ = client.SetPRDraft("owner", "repo", 1, true)
assert.Equal(t, "token my-secret-token", capturedAuth)
}
// --- PRMeta and Comment struct tests ---
func TestPRMeta_Fields(t *testing.T) {
meta := &PRMeta{
Number: 42,
Title: "Test PR",
State: "open",
Author: "testuser",
Branch: "feature/test",
BaseBranch: "main",
Labels: []string{"bug", "urgent"},
Assignees: []string{"dev1", "dev2"},
IsMerged: false,
CommentCount: 5,
}
assert.Equal(t, int64(42), meta.Number)
assert.Equal(t, "Test PR", meta.Title)
assert.Equal(t, "open", meta.State)
assert.Equal(t, "testuser", meta.Author)
assert.Equal(t, "feature/test", meta.Branch)
assert.Equal(t, "main", meta.BaseBranch)
assert.Equal(t, []string{"bug", "urgent"}, meta.Labels)
assert.Equal(t, []string{"dev1", "dev2"}, meta.Assignees)
assert.False(t, meta.IsMerged)
assert.Equal(t, 5, meta.CommentCount)
}
func TestComment_Fields(t *testing.T) {
comment := Comment{
ID: 123,
Author: "reviewer",
Body: "LGTM",
}
assert.Equal(t, int64(123), comment.ID)
assert.Equal(t, "reviewer", comment.Author)
assert.Equal(t, "LGTM", comment.Body)
}
// --- MergePullRequest merge style mapping ---
func TestMergePullRequest_StyleMapping(t *testing.T) {
// We can't easily test the SDK call, but we can verify the method
// errors when the server returns failure. This exercises the style mapping code.
tests := []struct {
name string
method string
}{
{name: "merge", method: "merge"},
{name: "squash", method: "squash"},
{name: "rebase", method: "rebase"},
{name: "default (unknown)", method: "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "1.21.0"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Return 405 to trigger an error so we know the code executed.
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client, err := New(srv.URL, "token")
require.NoError(t, err)
err = client.MergePullRequest("owner", "repo", 1, tt.method)
assert.Error(t, err, "merge should fail against mock server for method %s", tt.method)
})
}
}
// --- ListIssuesOpts defaulting ---
func TestListIssuesOpts_Defaults(t *testing.T) {
tests := []struct {
name string
opts ListIssuesOpts
expectedState string
expectedLimit int
expectedPage int
}{
{
name: "all defaults",
opts: ListIssuesOpts{},
expectedState: "open",
expectedLimit: 50,
expectedPage: 1,
},
{
name: "closed state",
opts: ListIssuesOpts{State: "closed"},
expectedState: "closed",
expectedLimit: 50,
expectedPage: 1,
},
{
name: "all state",
opts: ListIssuesOpts{State: "all"},
expectedState: "all",
expectedLimit: 50,
expectedPage: 1,
},
{
name: "custom limit and page",
opts: ListIssuesOpts{Page: 3, Limit: 25},
expectedState: "open",
expectedLimit: 25,
expectedPage: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Verify the opts struct stores values correctly.
if tt.opts.State == "" {
tt.opts.State = "open"
}
assert.Equal(t, tt.expectedState, tt.opts.State)
limit := tt.opts.Limit
if limit == 0 {
limit = 50
}
assert.Equal(t, tt.expectedLimit, limit)
page := tt.opts.Page
if page == 0 {
page = 1
}
assert.Equal(t, tt.expectedPage, page)
})
}
}
// --- ForkRepo error handling ---
func TestClient_ForkRepo_Good_WithOrg(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "1.21.0"})
})
var capturedBody map[string]any
mux.HandleFunc("/api/v1/repos/owner/repo/forks", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&capturedBody)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 1,
"name": "repo",
"full_name": "target-org/repo",
"owner": map[string]any{"login": "target-org"},
})
})
srv := httptest.NewServer(mux)
defer srv.Close()
client, err := New(srv.URL, "token")
require.NoError(t, err)
fork, err := client.ForkRepo("owner", "repo", "target-org")
require.NoError(t, err)
assert.NotNil(t, fork)
assert.Equal(t, "target-org", capturedBody["organization"])
}
func TestClient_ForkRepo_Good_WithoutOrg(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "1.21.0"})
})
var capturedBody map[string]any
mux.HandleFunc("/api/v1/repos/owner/repo/forks", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&capturedBody)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 2,
"name": "repo",
"full_name": "user/repo",
"owner": map[string]any{"login": "user"},
})
})
srv := httptest.NewServer(mux)
defer srv.Close()
client, err := New(srv.URL, "token")
require.NoError(t, err)
fork, err := client.ForkRepo("owner", "repo", "")
require.NoError(t, err)
assert.NotNil(t, fork)
// When org is empty, the Organization pointer is nil.
// The SDK may or may not include it in the JSON; just verify the fork succeeded.
}
func TestClient_ForkRepo_Bad_ServerError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "1.21.0"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Server Error", http.StatusInternalServerError)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client, err := New(srv.URL, "token")
require.NoError(t, err)
_, err = client.ForkRepo("owner", "repo", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to fork")
}
// --- CreatePullRequest error handling ---
func TestClient_CreatePullRequest_Bad_ServerError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "1.21.0"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Server Error", http.StatusInternalServerError)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client, err := New(srv.URL, "token")
require.NoError(t, err)
_, err = client.CreatePullRequest("owner", "repo", forgejo.CreatePullRequestOption{
Head: "feature",
Base: "main",
Title: "Test PR",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to create pull request")
}
// --- commentPageSize constant test ---
func TestCommentPageSize(t *testing.T) {
assert.Equal(t, 50, commentPageSize, "comment page size should be 50")
}
// --- ListPullRequests state mapping ---
func TestListPullRequests_StateMapping(t *testing.T) {
// Verify state mapping via error path (server returns error).
tests := []struct {
name string
state string
}{
{name: "open", state: "open"},
{name: "closed", state: "closed"},
{name: "all", state: "all"},
{name: "default", state: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "1.21.0"})
})
var capturedState string
mux.HandleFunc(fmt.Sprintf("/api/v1/repos/owner/repo/pulls"), func(w http.ResponseWriter, r *http.Request) {
capturedState = r.URL.Query().Get("state")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]any{})
})
srv := httptest.NewServer(mux)
defer srv.Close()
client, err := New(srv.URL, "token")
require.NoError(t, err)
_, _ = client.ListPullRequests("owner", "repo", tt.state)
// The state parameter was passed to the SDK and sent to the server.
assert.NotEmpty(t, capturedState)
})
}
}