- 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>
329 lines
8.7 KiB
Go
329 lines
8.7 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, nil)
|
|
|
|
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, nil)
|
|
|
|
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(), nil)
|
|
|
|
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(), nil)
|
|
|
|
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(), nil)
|
|
|
|
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, nil)
|
|
|
|
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, nil)
|
|
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
|
|
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, nil)
|
|
|
|
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, nil)
|
|
|
|
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(), nil)
|
|
|
|
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(), nil)
|
|
|
|
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(), nil)
|
|
|
|
err := svc.DrainWorker(ctx, "nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent worker")
|
|
}
|
|
})
|
|
}
|