package service import ( "context" "testing" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) func TestWorkerService_Register(t *testing.T) { ctx := context.Background() t.Run("registers valid worker", func(t *testing.T) { registry := newMockWorkerRegistry() queue := newMockWorkQueue() svc := NewWorkerService(registry, queue) err := svc.Register(ctx, &domain.Worker{ ID: "worker-1", Hostname: "host-1", Capabilities: []string{"build"}, Version: "1.0.0", }) if err != nil { t.Fatalf("Register() error = %v", err) } w := registry.workers["worker-1"] if w == nil { t.Fatal("worker not found in registry") } if w.Status != domain.WorkerStatusIdle { t.Errorf("got status %q, want %q", w.Status, domain.WorkerStatusIdle) } if w.RegisteredAt.IsZero() { t.Error("expected registered_at to be set") } }) t.Run("validates worker", func(t *testing.T) { registry := newMockWorkerRegistry() queue := newMockWorkQueue() svc := NewWorkerService(registry, queue) err := svc.Register(ctx, &domain.Worker{}) if err == nil { t.Error("expected validation error for empty worker") } }) } func TestWorkerService_Heartbeat(t *testing.T) { ctx := context.Background() t.Run("updates heartbeat", func(t *testing.T) { registry := newMockWorkerRegistry() registry.workers["worker-1"] = &domain.Worker{ ID: "worker-1", Hostname: "host-1", Status: domain.WorkerStatusIdle, LastHeartbeat: time.Now().Add(-30 * time.Second), } svc := NewWorkerService(registry, newMockWorkQueue()) err := svc.Heartbeat(ctx, "worker-1") if err != nil { t.Fatalf("Heartbeat() error = %v", err) } // Heartbeat should be recent w := registry.workers["worker-1"] if time.Since(w.LastHeartbeat) > time.Second { t.Error("expected heartbeat to be updated to now") } }) t.Run("returns error for nonexistent worker", func(t *testing.T) { registry := newMockWorkerRegistry() svc := NewWorkerService(registry, newMockWorkQueue()) err := svc.Heartbeat(ctx, "nonexistent") if err == nil { t.Error("expected error for nonexistent worker") } }) } func TestWorkerService_Deregister(t *testing.T) { ctx := context.Background() t.Run("deregisters worker", func(t *testing.T) { registry := newMockWorkerRegistry() registry.workers["worker-1"] = &domain.Worker{ ID: "worker-1", Hostname: "host-1", Status: domain.WorkerStatusIdle, } svc := NewWorkerService(registry, newMockWorkQueue()) err := svc.Deregister(ctx, "worker-1") if err != nil { t.Fatalf("Deregister() error = %v", err) } if _, ok := registry.workers["worker-1"]; ok { t.Error("worker should be removed from registry") } }) } func TestWorkerService_ClaimTask(t *testing.T) { ctx := context.Background() t.Run("claims task and marks worker busy", func(t *testing.T) { registry := newMockWorkerRegistry() registry.workers["worker-1"] = &domain.Worker{ ID: "worker-1", Hostname: "host-1", Status: domain.WorkerStatusIdle, } queue := newMockWorkQueue() queue.tasks["task-1"] = &domain.WorkTask{ ID: "task-1", ProjectID: "project-1", Type: domain.WorkTaskTypeBuild, Status: domain.WorkTaskStatusPending, CreatedAt: time.Now(), } svc := NewWorkerService(registry, queue) task, err := svc.ClaimTask(ctx, "worker-1") if err != nil { t.Fatalf("ClaimTask() error = %v", err) } if task == nil { t.Fatal("expected task to be returned") } if task.ID != "task-1" { t.Errorf("got task ID %q, want %q", task.ID, "task-1") } // Worker should be busy with the task w := registry.workers["worker-1"] if w.Status != domain.WorkerStatusBusy { t.Errorf("got status %q, want %q", w.Status, domain.WorkerStatusBusy) } if w.CurrentTask != "task-1" { t.Errorf("got current_task %q, want %q", w.CurrentTask, "task-1") } }) t.Run("returns nil when no tasks available", func(t *testing.T) { registry := newMockWorkerRegistry() registry.workers["worker-1"] = &domain.Worker{ ID: "worker-1", Hostname: "host-1", Status: domain.WorkerStatusIdle, } queue := newMockWorkQueue() svc := NewWorkerService(registry, queue) task, err := svc.ClaimTask(ctx, "worker-1") if err != nil { t.Fatalf("ClaimTask() error = %v", err) } if task != nil { t.Error("expected nil task when queue is empty") } }) t.Run("updates audit status when claiming task", func(t *testing.T) { registry := newMockWorkerRegistry() registry.workers["worker-1"] = &domain.Worker{ ID: "worker-1", Hostname: "host-1", Status: domain.WorkerStatusIdle, } queue := newMockWorkQueue() queue.tasks["task-1"] = &domain.WorkTask{ ID: "task-1", ProjectID: "project-1", Type: domain.WorkTaskTypeBuild, Status: domain.WorkTaskStatusPending, CreatedAt: time.Now(), } audit := newMockBuildAudit() audit.entries["task-1"] = &domain.BuildAuditEntry{ TaskID: "task-1", ProjectID: "project-1", Status: domain.BuildStatusPending, } svc := NewWorkerService(registry, queue).WithBuildAudit(audit) task, err := svc.ClaimTask(ctx, "worker-1") if err != nil { t.Fatalf("ClaimTask() error = %v", err) } if task == nil { t.Fatal("expected task to be returned") } // Verify audit was updated entry := audit.entries["task-1"] if entry.Status != domain.BuildStatusRunning { t.Errorf("got audit status %q, want %q", entry.Status, domain.BuildStatusRunning) } if entry.WorkerID != "worker-1" { t.Errorf("got audit worker_id %q, want %q", entry.WorkerID, "worker-1") } }) } func TestWorkerService_CompleteTask(t *testing.T) { ctx := context.Background() t.Run("completes task and returns worker to idle", func(t *testing.T) { registry := newMockWorkerRegistry() registry.workers["worker-1"] = &domain.Worker{ ID: "worker-1", Hostname: "host-1", Status: domain.WorkerStatusBusy, CurrentTask: "task-1", } queue := newMockWorkQueue() queue.tasks["task-1"] = &domain.WorkTask{ ID: "task-1", ProjectID: "project-1", Type: domain.WorkTaskTypeBuild, Status: domain.WorkTaskStatusRunning, WorkerID: "worker-1", } svc := NewWorkerService(registry, queue) err := svc.CompleteTask(ctx, "worker-1", "task-1", &domain.BuildResult{ Success: true, CommitSHA: "abc123", DurationMs: 5000, }) if err != nil { t.Fatalf("CompleteTask() error = %v", err) } // Task should be completed task := queue.tasks["task-1"] if task.Status != domain.WorkTaskStatusCompleted { t.Errorf("got task status %q, want %q", task.Status, domain.WorkTaskStatusCompleted) } // Worker should be idle w := registry.workers["worker-1"] if w.Status != domain.WorkerStatusIdle { t.Errorf("got worker status %q, want %q", w.Status, domain.WorkerStatusIdle) } if w.CurrentTask != "" { t.Errorf("got current_task %q, want empty", w.CurrentTask) } }) t.Run("handles nil result", func(t *testing.T) { registry := newMockWorkerRegistry() registry.workers["worker-1"] = &domain.Worker{ ID: "worker-1", Hostname: "host-1", Status: domain.WorkerStatusBusy, CurrentTask: "task-1", } queue := newMockWorkQueue() queue.tasks["task-1"] = &domain.WorkTask{ ID: "task-1", Status: domain.WorkTaskStatusRunning, WorkerID: "worker-1", } svc := NewWorkerService(registry, queue) err := svc.CompleteTask(ctx, "worker-1", "task-1", nil) if err != nil { t.Fatalf("CompleteTask(nil result) error = %v", err) } // Worker should be idle w := registry.workers["worker-1"] if w.Status != domain.WorkerStatusIdle { t.Errorf("got worker status %q, want %q", w.Status, domain.WorkerStatusIdle) } }) } func TestWorkerService_ListWorkers(t *testing.T) { ctx := context.Background() registry := newMockWorkerRegistry() registry.workers["worker-1"] = &domain.Worker{ID: "worker-1", Status: domain.WorkerStatusIdle} registry.workers["worker-2"] = &domain.Worker{ID: "worker-2", Status: domain.WorkerStatusBusy} registry.workers["worker-3"] = &domain.Worker{ID: "worker-3", Status: domain.WorkerStatusIdle} svc := NewWorkerService(registry, newMockWorkQueue()) t.Run("lists all workers", func(t *testing.T) { workers, err := svc.ListWorkers(ctx, port.WorkerFilter{}) if err != nil { t.Fatalf("ListWorkers() error = %v", err) } if len(workers) != 3 { t.Errorf("got %d workers, want 3", len(workers)) } }) t.Run("filters by status", func(t *testing.T) { idle := domain.WorkerStatusIdle workers, err := svc.ListWorkers(ctx, port.WorkerFilter{Status: &idle}) if err != nil { t.Fatalf("ListWorkers() error = %v", err) } if len(workers) != 2 { t.Errorf("got %d idle workers, want 2", len(workers)) } }) } func TestWorkerService_DrainWorker(t *testing.T) { ctx := context.Background() t.Run("drains worker", func(t *testing.T) { registry := newMockWorkerRegistry() registry.workers["worker-1"] = &domain.Worker{ ID: "worker-1", Hostname: "host-1", Status: domain.WorkerStatusBusy, CurrentTask: "task-1", } svc := NewWorkerService(registry, newMockWorkQueue()) err := svc.DrainWorker(ctx, "worker-1") if err != nil { t.Fatalf("DrainWorker() error = %v", err) } w := registry.workers["worker-1"] if w.Status != domain.WorkerStatusDraining { t.Errorf("got status %q, want %q", w.Status, domain.WorkerStatusDraining) } // Should preserve current task if w.CurrentTask != "task-1" { t.Errorf("got current_task %q, want %q", w.CurrentTask, "task-1") } }) t.Run("returns error for nonexistent worker", func(t *testing.T) { registry := newMockWorkerRegistry() svc := NewWorkerService(registry, newMockWorkQueue()) err := svc.DrainWorker(ctx, "nonexistent") if err == nil { t.Error("expected error for nonexistent worker") } }) }