Major changes: - Add internal/logging package with field constants, context propagation, sensitive data auto-redaction, and per-component log levels - Add worker timeout constants (TimeoutQuickOp, TimeoutHealthCheck, etc.) - Extend SDLC with callback handlers, generate endpoints, and executor - Add new cookbook trees for aeries and slackpath progression - Add skeleton templates for queue, realtime, and microservices - Add worker component template with async job processing - Refactor services and handlers to use new logging infrastructure - Split component.go into component_infra.go and component_listing.go Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
373 lines
9.8 KiB
Go
373 lines
9.8 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|