Move distributed bug fixing app from core/cli internal/bugseti/ and cmd/bugseti/ into its own module. Library code at package root, app entry point in cmd/, design docs in docs/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
558 lines
15 KiB
Go
558 lines
15 KiB
Go
package bugseti
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func testHubService(t *testing.T, serverURL string) *HubService {
|
|
t.Helper()
|
|
cfg := testConfigService(t, nil, nil)
|
|
if serverURL != "" {
|
|
cfg.config.HubURL = serverURL
|
|
}
|
|
return NewHubService(cfg)
|
|
}
|
|
|
|
// ---- NewHubService ----
|
|
|
|
func TestNewHubService_Good(t *testing.T) {
|
|
h := testHubService(t, "")
|
|
require.NotNil(t, h)
|
|
assert.NotNil(t, h.config)
|
|
assert.NotNil(t, h.client)
|
|
assert.False(t, h.IsConnected())
|
|
}
|
|
|
|
func TestHubServiceName_Good(t *testing.T) {
|
|
h := testHubService(t, "")
|
|
assert.Equal(t, "HubService", h.ServiceName())
|
|
}
|
|
|
|
func TestNewHubService_Good_GeneratesClientID(t *testing.T) {
|
|
h := testHubService(t, "")
|
|
id := h.GetClientID()
|
|
assert.NotEmpty(t, id)
|
|
// 16 bytes = 32 hex characters
|
|
assert.Len(t, id, 32)
|
|
}
|
|
|
|
func TestNewHubService_Good_ReusesClientID(t *testing.T) {
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.ClientID = "existing-client-id"
|
|
|
|
h := NewHubService(cfg)
|
|
assert.Equal(t, "existing-client-id", h.GetClientID())
|
|
}
|
|
|
|
// ---- doRequest ----
|
|
|
|
func TestDoRequest_Good(t *testing.T) {
|
|
var gotAuth string
|
|
var gotContentType string
|
|
var gotAccept string
|
|
var gotBody map[string]string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotContentType = r.Header.Get("Content-Type")
|
|
gotAccept = r.Header.Get("Accept")
|
|
|
|
if r.Body != nil {
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "test-token-123"
|
|
h := NewHubService(cfg)
|
|
|
|
body := map[string]string{"key": "value"}
|
|
resp, err := h.doRequest("POST", "/test", body)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Equal(t, "Bearer test-token-123", gotAuth)
|
|
assert.Equal(t, "application/json", gotContentType)
|
|
assert.Equal(t, "application/json", gotAccept)
|
|
assert.Equal(t, "value", gotBody["key"])
|
|
assert.True(t, h.IsConnected())
|
|
}
|
|
|
|
func TestDoRequest_Bad_NoHubURL(t *testing.T) {
|
|
h := testHubService(t, "")
|
|
|
|
resp, err := h.doRequest("GET", "/test", nil)
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "hub URL not configured")
|
|
}
|
|
|
|
func TestDoRequest_Bad_NetworkError(t *testing.T) {
|
|
// Point to a port where nothing is listening.
|
|
h := testHubService(t, "http://127.0.0.1:1")
|
|
|
|
resp, err := h.doRequest("GET", "/test", nil)
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
assert.False(t, h.IsConnected())
|
|
}
|
|
|
|
// ---- Task 4: AutoRegister ----
|
|
|
|
func TestAutoRegister_Good(t *testing.T) {
|
|
var gotBody map[string]string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/api/bugseti/auth/forge", r.URL.Path)
|
|
assert.Equal(t, "POST", r.Method)
|
|
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"api_key":"ak_test_12345"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.ForgeURL = "https://forge.example.com"
|
|
cfg.config.ForgeToken = "forge-tok-abc"
|
|
h := NewHubService(cfg)
|
|
|
|
err := h.AutoRegister()
|
|
require.NoError(t, err)
|
|
|
|
// Verify token was cached.
|
|
assert.Equal(t, "ak_test_12345", h.config.GetHubToken())
|
|
|
|
// Verify request body.
|
|
assert.Equal(t, "https://forge.example.com", gotBody["forge_url"])
|
|
assert.Equal(t, "forge-tok-abc", gotBody["forge_token"])
|
|
assert.NotEmpty(t, gotBody["client_id"])
|
|
}
|
|
|
|
func TestAutoRegister_Bad_NoForgeToken(t *testing.T) {
|
|
// Isolate from user's real ~/.core/config.yaml and env vars.
|
|
origHome := os.Getenv("HOME")
|
|
t.Setenv("HOME", t.TempDir())
|
|
t.Setenv("FORGE_TOKEN", "")
|
|
t.Setenv("FORGE_URL", "")
|
|
defer os.Setenv("HOME", origHome)
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = "https://hub.example.com"
|
|
// No forge token set, and env/config are empty in test.
|
|
h := NewHubService(cfg)
|
|
|
|
err := h.AutoRegister()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "no forge token available")
|
|
}
|
|
|
|
func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) {
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = "https://hub.example.com"
|
|
cfg.config.HubToken = "existing-token"
|
|
h := NewHubService(cfg)
|
|
|
|
err := h.AutoRegister()
|
|
require.NoError(t, err)
|
|
|
|
// Token should remain unchanged.
|
|
assert.Equal(t, "existing-token", h.config.GetHubToken())
|
|
}
|
|
|
|
// ---- Task 5: Write Operations ----
|
|
|
|
func TestRegister_Good(t *testing.T) {
|
|
var gotPath string
|
|
var gotMethod string
|
|
var gotBody map[string]string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotMethod = r.Method
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "tok"
|
|
cfg.config.ClientName = "MyBugSETI"
|
|
h := NewHubService(cfg)
|
|
|
|
err := h.Register()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "/api/bugseti/register", gotPath)
|
|
assert.Equal(t, "POST", gotMethod)
|
|
assert.Equal(t, "MyBugSETI", gotBody["name"])
|
|
assert.NotEmpty(t, gotBody["client_id"])
|
|
assert.NotEmpty(t, gotBody["version"])
|
|
assert.NotEmpty(t, gotBody["os"])
|
|
assert.NotEmpty(t, gotBody["arch"])
|
|
}
|
|
|
|
func TestHeartbeat_Good(t *testing.T) {
|
|
var gotPath string
|
|
var gotMethod string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotMethod = r.Method
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "tok"
|
|
h := NewHubService(cfg)
|
|
|
|
err := h.Heartbeat()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "/api/bugseti/heartbeat", gotPath)
|
|
assert.Equal(t, "POST", gotMethod)
|
|
}
|
|
|
|
func TestClaimIssue_Good(t *testing.T) {
|
|
now := time.Now().Truncate(time.Second)
|
|
expires := now.Add(30 * time.Minute)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/api/bugseti/issues/claim", r.URL.Path)
|
|
assert.Equal(t, "POST", r.Method)
|
|
|
|
var body map[string]interface{}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
assert.Equal(t, "issue-42", body["issue_id"])
|
|
assert.Equal(t, "org/repo", body["repo"])
|
|
assert.Equal(t, float64(42), body["issue_number"])
|
|
assert.Equal(t, "Fix the bug", body["title"])
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
resp := HubClaim{
|
|
ID: "claim-1",
|
|
IssueURL: "https://github.com/org/repo/issues/42",
|
|
ClientID: "test",
|
|
ClaimedAt: now,
|
|
ExpiresAt: expires,
|
|
Status: "claimed",
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "tok"
|
|
h := NewHubService(cfg)
|
|
|
|
issue := &Issue{
|
|
ID: "issue-42",
|
|
Number: 42,
|
|
Repo: "org/repo",
|
|
Title: "Fix the bug",
|
|
URL: "https://github.com/org/repo/issues/42",
|
|
}
|
|
|
|
claim, err := h.ClaimIssue(issue)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, claim)
|
|
assert.Equal(t, "claim-1", claim.ID)
|
|
assert.Equal(t, "claimed", claim.Status)
|
|
}
|
|
|
|
func TestClaimIssue_Bad_Conflict(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusConflict)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "tok"
|
|
h := NewHubService(cfg)
|
|
|
|
issue := &Issue{ID: "issue-99", Number: 99, Repo: "org/repo", Title: "Already claimed"}
|
|
|
|
claim, err := h.ClaimIssue(issue)
|
|
assert.Nil(t, claim)
|
|
require.Error(t, err)
|
|
|
|
var conflictErr *ConflictError
|
|
assert.ErrorAs(t, err, &conflictErr)
|
|
}
|
|
|
|
func TestUpdateStatus_Good(t *testing.T) {
|
|
var gotPath string
|
|
var gotMethod string
|
|
var gotBody map[string]interface{}
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotMethod = r.Method
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "tok"
|
|
h := NewHubService(cfg)
|
|
|
|
err := h.UpdateStatus("issue-42", "completed", "https://github.com/org/repo/pull/10", 10)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "PATCH", gotMethod)
|
|
assert.Equal(t, "/api/bugseti/issues/issue-42/status", gotPath)
|
|
assert.Equal(t, "completed", gotBody["status"])
|
|
assert.Equal(t, "https://github.com/org/repo/pull/10", gotBody["pr_url"])
|
|
assert.Equal(t, float64(10), gotBody["pr_number"])
|
|
}
|
|
|
|
func TestSyncStats_Good(t *testing.T) {
|
|
var gotBody map[string]interface{}
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/api/bugseti/stats/sync", r.URL.Path)
|
|
assert.Equal(t, "POST", r.Method)
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "tok"
|
|
h := NewHubService(cfg)
|
|
|
|
stats := &Stats{
|
|
IssuesAttempted: 10,
|
|
IssuesCompleted: 7,
|
|
IssuesSkipped: 3,
|
|
PRsSubmitted: 6,
|
|
PRsMerged: 5,
|
|
PRsRejected: 1,
|
|
CurrentStreak: 3,
|
|
LongestStreak: 5,
|
|
TotalTimeSpent: 90 * time.Minute,
|
|
ReposContributed: map[string]*RepoStats{
|
|
"org/repo-a": {Name: "org/repo-a"},
|
|
"org/repo-b": {Name: "org/repo-b"},
|
|
},
|
|
}
|
|
|
|
err := h.SyncStats(stats)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotEmpty(t, gotBody["client_id"])
|
|
statsMap, ok := gotBody["stats"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, float64(10), statsMap["issues_attempted"])
|
|
assert.Equal(t, float64(7), statsMap["issues_completed"])
|
|
assert.Equal(t, float64(3), statsMap["issues_skipped"])
|
|
assert.Equal(t, float64(6), statsMap["prs_submitted"])
|
|
assert.Equal(t, float64(5), statsMap["prs_merged"])
|
|
assert.Equal(t, float64(1), statsMap["prs_rejected"])
|
|
assert.Equal(t, float64(3), statsMap["current_streak"])
|
|
assert.Equal(t, float64(5), statsMap["longest_streak"])
|
|
assert.Equal(t, float64(90), statsMap["total_time_minutes"])
|
|
|
|
reposRaw, ok := statsMap["repos_contributed"].([]interface{})
|
|
require.True(t, ok)
|
|
assert.Len(t, reposRaw, 2)
|
|
}
|
|
|
|
// ---- Task 6: Read Operations ----
|
|
|
|
func TestIsIssueClaimed_Good_Claimed(t *testing.T) {
|
|
now := time.Now().Truncate(time.Second)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/api/bugseti/issues/issue-42", r.URL.Path)
|
|
assert.Equal(t, "GET", r.Method)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
claim := HubClaim{
|
|
ID: "claim-1",
|
|
IssueURL: "https://github.com/org/repo/issues/42",
|
|
ClientID: "client-abc",
|
|
ClaimedAt: now,
|
|
Status: "claimed",
|
|
}
|
|
_ = json.NewEncoder(w).Encode(claim)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "tok"
|
|
h := NewHubService(cfg)
|
|
|
|
claim, err := h.IsIssueClaimed("issue-42")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, claim)
|
|
assert.Equal(t, "claim-1", claim.ID)
|
|
assert.Equal(t, "claimed", claim.Status)
|
|
}
|
|
|
|
func TestIsIssueClaimed_Good_NotClaimed(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "tok"
|
|
h := NewHubService(cfg)
|
|
|
|
claim, err := h.IsIssueClaimed("issue-999")
|
|
assert.NoError(t, err)
|
|
assert.Nil(t, claim)
|
|
}
|
|
|
|
func TestGetLeaderboard_Good(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/api/bugseti/leaderboard", r.URL.Path)
|
|
assert.Equal(t, "GET", r.Method)
|
|
assert.Equal(t, "10", r.URL.Query().Get("limit"))
|
|
|
|
resp := leaderboardResponse{
|
|
Entries: []LeaderboardEntry{
|
|
{ClientID: "a", ClientName: "Alice", Score: 100, PRsMerged: 10, Rank: 1},
|
|
{ClientID: "b", ClientName: "Bob", Score: 80, PRsMerged: 8, Rank: 2},
|
|
},
|
|
TotalParticipants: 42,
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "tok"
|
|
h := NewHubService(cfg)
|
|
|
|
entries, total, err := h.GetLeaderboard(10)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 42, total)
|
|
require.Len(t, entries, 2)
|
|
assert.Equal(t, "Alice", entries[0].ClientName)
|
|
assert.Equal(t, 1, entries[0].Rank)
|
|
assert.Equal(t, "Bob", entries[1].ClientName)
|
|
}
|
|
|
|
func TestGetGlobalStats_Good(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/api/bugseti/stats", r.URL.Path)
|
|
assert.Equal(t, "GET", r.Method)
|
|
|
|
stats := GlobalStats{
|
|
TotalClients: 100,
|
|
TotalClaims: 500,
|
|
TotalPRsMerged: 300,
|
|
ActiveClaims: 25,
|
|
IssuesAvailable: 150,
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(stats)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "tok"
|
|
h := NewHubService(cfg)
|
|
|
|
stats, err := h.GetGlobalStats()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stats)
|
|
assert.Equal(t, 100, stats.TotalClients)
|
|
assert.Equal(t, 500, stats.TotalClaims)
|
|
assert.Equal(t, 300, stats.TotalPRsMerged)
|
|
assert.Equal(t, 25, stats.ActiveClaims)
|
|
assert.Equal(t, 150, stats.IssuesAvailable)
|
|
}
|
|
|
|
// ---- Task 7: Pending Operations Queue ----
|
|
|
|
func TestPendingOps_Good_QueueAndDrain(t *testing.T) {
|
|
var callCount int32
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
callCount++
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = srv.URL
|
|
cfg.config.HubToken = "tok"
|
|
h := NewHubService(cfg)
|
|
|
|
// Manually queue a pending op (simulates a previous failed request).
|
|
h.queueOp("POST", "/heartbeat", map[string]string{"client_id": "test"})
|
|
assert.Equal(t, 1, h.PendingCount())
|
|
|
|
// Register() calls drainPendingOps() first, then sends its own request.
|
|
err := h.Register()
|
|
require.NoError(t, err)
|
|
|
|
// At least 2 calls: 1 from drain (the queued heartbeat) + 1 from Register itself.
|
|
assert.GreaterOrEqual(t, callCount, int32(2))
|
|
assert.Equal(t, 0, h.PendingCount())
|
|
}
|
|
|
|
func TestPendingOps_Good_PersistAndLoad(t *testing.T) {
|
|
cfg1 := testConfigService(t, nil, nil)
|
|
cfg1.config.HubURL = "https://hub.example.com"
|
|
cfg1.config.HubToken = "tok"
|
|
h1 := NewHubService(cfg1)
|
|
|
|
// Queue an op — this also calls savePendingOps.
|
|
h1.queueOp("POST", "/heartbeat", map[string]string{"client_id": "test"})
|
|
assert.Equal(t, 1, h1.PendingCount())
|
|
|
|
// Create a second HubService with the same data dir.
|
|
// NewHubService calls loadPendingOps() in its constructor.
|
|
cfg2 := testConfigService(t, nil, nil)
|
|
cfg2.config.DataDir = cfg1.config.DataDir // Share the same data dir.
|
|
cfg2.config.HubURL = "https://hub.example.com"
|
|
cfg2.config.HubToken = "tok"
|
|
h2 := NewHubService(cfg2)
|
|
|
|
assert.Equal(t, 1, h2.PendingCount())
|
|
}
|
|
|
|
func TestPendingCount_Good(t *testing.T) {
|
|
cfg := testConfigService(t, nil, nil)
|
|
cfg.config.HubURL = "https://hub.example.com"
|
|
cfg.config.HubToken = "tok"
|
|
h := NewHubService(cfg)
|
|
|
|
assert.Equal(t, 0, h.PendingCount())
|
|
|
|
h.queueOp("POST", "/test1", nil)
|
|
assert.Equal(t, 1, h.PendingCount())
|
|
|
|
h.queueOp("POST", "/test2", map[string]string{"key": "val"})
|
|
assert.Equal(t, 2, h.PendingCount())
|
|
}
|