- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
305 lines
7.6 KiB
Go
305 lines
7.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/service"
|
|
)
|
|
|
|
// mockWorkerRegistry implements port.WorkerRegistry for testing.
|
|
type mockWorkerRegistry struct {
|
|
workers map[string]*domain.Worker
|
|
err error
|
|
}
|
|
|
|
func newMockWorkerRegistry() *mockWorkerRegistry {
|
|
return &mockWorkerRegistry{
|
|
workers: make(map[string]*domain.Worker),
|
|
}
|
|
}
|
|
|
|
func (m *mockWorkerRegistry) Register(_ context.Context, w *domain.Worker) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
m.workers[w.ID] = w
|
|
return nil
|
|
}
|
|
|
|
func (m *mockWorkerRegistry) Heartbeat(_ context.Context, workerID string) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
w, ok := m.workers[workerID]
|
|
if !ok {
|
|
return domain.ErrWorkerNotFound
|
|
}
|
|
w.LastHeartbeat = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (m *mockWorkerRegistry) UpdateStatus(_ context.Context, workerID string, status domain.WorkerStatus, taskID string) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
w, ok := m.workers[workerID]
|
|
if !ok {
|
|
return domain.ErrWorkerNotFound
|
|
}
|
|
w.Status = status
|
|
w.CurrentTask = taskID
|
|
return nil
|
|
}
|
|
|
|
func (m *mockWorkerRegistry) Deregister(_ context.Context, workerID string) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
delete(m.workers, workerID)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockWorkerRegistry) Get(_ context.Context, workerID string) (*domain.Worker, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
w, ok := m.workers[workerID]
|
|
if !ok {
|
|
return nil, domain.ErrWorkerNotFound
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
func (m *mockWorkerRegistry) List(_ context.Context, filter port.WorkerFilter) ([]*domain.Worker, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
var result []*domain.Worker
|
|
for _, w := range m.workers {
|
|
if filter.Status != nil && w.Status != *filter.Status {
|
|
continue
|
|
}
|
|
result = append(result, w)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockWorkerRegistry) MarkStaleOffline(_ context.Context, _ time.Duration) (int, error) {
|
|
return 0, m.err
|
|
}
|
|
|
|
func TestWorkersHandler_List(t *testing.T) {
|
|
registry := newMockWorkerRegistry()
|
|
queue := newMockWorkQueue()
|
|
workerService := service.NewWorkerService(registry, queue, nil)
|
|
handler := NewWorkersHandler(workerService)
|
|
|
|
// Populate workers
|
|
registry.workers["worker-1"] = &domain.Worker{
|
|
ID: "worker-1",
|
|
Hostname: "host-1",
|
|
Status: domain.WorkerStatusIdle,
|
|
Capabilities: []string{"build"},
|
|
RegisteredAt: time.Now(),
|
|
LastHeartbeat: time.Now(),
|
|
Version: "1.0.0",
|
|
}
|
|
registry.workers["worker-2"] = &domain.Worker{
|
|
ID: "worker-2",
|
|
Hostname: "host-2",
|
|
Status: domain.WorkerStatusBusy,
|
|
CurrentTask: "task-abc",
|
|
Capabilities: []string{"build", "deploy"},
|
|
RegisteredAt: time.Now(),
|
|
LastHeartbeat: time.Now(),
|
|
Version: "1.0.0",
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
handler.Mount(router)
|
|
|
|
t.Run("list_all_workers", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/workers", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
data, ok := resp["data"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected data to be map, got %T", resp["data"])
|
|
}
|
|
totalF, ok := data["total"].(float64)
|
|
if !ok {
|
|
t.Fatalf("expected total to be float64, got %T", data["total"])
|
|
}
|
|
if int(totalF) != 2 {
|
|
t.Errorf("got total=%d, want 2", int(totalF))
|
|
}
|
|
|
|
summary, ok := data["summary"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected summary to be map, got %T", data["summary"])
|
|
}
|
|
if idleF, ok := summary["idle"].(float64); !ok || int(idleF) != 1 {
|
|
t.Errorf("got idle=%v, want 1", summary["idle"])
|
|
}
|
|
if busyF, ok := summary["busy"].(float64); !ok || int(busyF) != 1 {
|
|
t.Errorf("got busy=%v, want 1", summary["busy"])
|
|
}
|
|
})
|
|
|
|
t.Run("filter_by_status", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/workers?status=idle", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("got status %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal: %v", err)
|
|
}
|
|
data, ok := resp["data"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected data to be map, got %T", resp["data"])
|
|
}
|
|
totalF, ok := data["total"].(float64)
|
|
if !ok {
|
|
t.Fatalf("expected total to be float64, got %T", data["total"])
|
|
}
|
|
if int(totalF) != 1 {
|
|
t.Errorf("got total=%d, want 1", int(totalF))
|
|
}
|
|
})
|
|
|
|
t.Run("invalid_status_filter", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/workers?status=invalid", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWorkersHandler_Get(t *testing.T) {
|
|
registry := newMockWorkerRegistry()
|
|
queue := newMockWorkQueue()
|
|
workerService := service.NewWorkerService(registry, queue, nil)
|
|
handler := NewWorkersHandler(workerService)
|
|
|
|
registry.workers["worker-1"] = &domain.Worker{
|
|
ID: "worker-1",
|
|
Hostname: "host-1",
|
|
Status: domain.WorkerStatusIdle,
|
|
RegisteredAt: time.Now(),
|
|
LastHeartbeat: time.Now(),
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
handler.Mount(router)
|
|
|
|
tests := []struct {
|
|
name string
|
|
workerID string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "existing_worker",
|
|
workerID: "worker-1",
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "not_found",
|
|
workerID: "nonexistent",
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/workers/"+tt.workerID, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != tt.wantStatus {
|
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWorkersHandler_Drain(t *testing.T) {
|
|
registry := newMockWorkerRegistry()
|
|
queue := newMockWorkQueue()
|
|
workerService := service.NewWorkerService(registry, queue, nil)
|
|
handler := NewWorkersHandler(workerService)
|
|
|
|
registry.workers["worker-1"] = &domain.Worker{
|
|
ID: "worker-1",
|
|
Hostname: "host-1",
|
|
Status: domain.WorkerStatusBusy,
|
|
CurrentTask: "task-abc",
|
|
RegisteredAt: time.Now(),
|
|
LastHeartbeat: time.Now(),
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
handler.Mount(router)
|
|
|
|
tests := []struct {
|
|
name string
|
|
workerID string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "drain_existing_worker",
|
|
workerID: "worker-1",
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "drain_nonexistent_worker",
|
|
workerID: "nonexistent",
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/workers/"+tt.workerID+"/drain", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != tt.wantStatus {
|
|
t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// Verify the worker was actually set to draining
|
|
if registry.workers["worker-1"].Status != domain.WorkerStatusDraining {
|
|
t.Errorf("expected worker status to be draining, got %s", registry.workers["worker-1"].Status)
|
|
}
|
|
}
|