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