rdev/internal/handlers/workers_test.go
jordan bc47e426b0 feat: Add CI pipeline proxy, DNS alias management, and worker executor system
- 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>
2026-01-27 21:05:28 -07:00

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)
}
}