rdev/internal/worker/work_executor_test.go
jordan 53862c773b fix: resolve systemic debt in worker and skeleton templates
Worker template fixes:
- Replace panic() with logger.Error() + os.Exit(1) for config errors
- Remove double-timeout application (context + middleware)
- Add error message truncation to prevent log bloat
- Use named constants for shutdown grace period and stale check interval

Skeleton pkg/auth fixes:
- Fix error wrapping to use %w consistently in jwt.go
- Add GetUserOrError() as safe alternative to MustGetUser() panic

Skeleton pkg/queue fixes:
- Check RowsAffected() errors instead of ignoring them
- Add input validation to EnqueueWithOptions (require job type, cap retries)
- Add log truncation for error messages
- Fix inaccurate doc comment claiming exponential backoff

Worker timeout consolidation:
- Add internal/worker/timeouts.go with named constants
- Migrate all workers to use timeout constants

Cleanup:
- Remove obsolete slack-preparation-thoughts.md files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:44:55 -07:00

334 lines
8.9 KiB
Go

package worker
import (
"context"
"fmt"
"testing"
"time"
"github.com/orchard9/rdev/internal/domain"
)
func TestWorkExecutor_StartAndStop(t *testing.T) {
deps := newTestDeps()
executor := NewWorkExecutor(deps.workerSvc, deps.workSvc, deps.buildExec, nil, nil, &WorkExecutorConfig{
WorkerID: "test-worker-1",
PollPeriod: 100 * time.Millisecond,
HeartbeatPeriod: 100 * time.Millisecond,
})
if err := executor.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
// Verify worker was registered
deps.registry.mu.Lock()
w, exists := deps.registry.workers["test-worker-1"]
deps.registry.mu.Unlock()
if !exists {
t.Fatal("expected worker to be registered")
}
if w.Status != domain.WorkerStatusIdle {
t.Errorf("got status %q, want %q", w.Status, domain.WorkerStatusIdle)
}
// Verify double-start returns error
if err := executor.Start(); err == nil {
t.Error("expected error on double-start")
}
executor.Stop()
// Verify worker was deregistered
deps.registry.mu.Lock()
_, exists = deps.registry.workers["test-worker-1"]
deps.registry.mu.Unlock()
if exists {
t.Error("expected worker to be deregistered after stop")
}
}
func TestWorkExecutor_ClaimsAndExecutesTask(t *testing.T) {
deps := newTestDeps()
// Enqueue a build task
deps.queue.mu.Lock()
deps.queue.tasks["task-1"] = &domain.WorkTask{
ID: "task-1",
ProjectID: "project-1",
Type: domain.WorkTaskTypeBuild,
Status: domain.WorkTaskStatusPending,
Spec: map[string]any{"prompt": "Build a landing page"},
MaxRetries: 3,
CreatedAt: time.Now(),
}
deps.queue.mu.Unlock()
executor := NewWorkExecutor(deps.workerSvc, deps.workSvc, deps.buildExec, nil, nil, &WorkExecutorConfig{
WorkerID: "test-worker-2",
PollPeriod: 50 * time.Millisecond,
HeartbeatPeriod: 5 * time.Second,
})
// Register the worker (normally done by Start) then call tryClaimAndExecute directly
if err := executor.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
// Call tryClaimAndExecute directly to avoid timing dependency
executor.tryClaimAndExecute()
executor.Stop()
// Verify task was completed
deps.queue.mu.Lock()
task := deps.queue.tasks["task-1"]
deps.queue.mu.Unlock()
if task.Status != domain.WorkTaskStatusCompleted {
t.Errorf("got task status %q, want %q", task.Status, domain.WorkTaskStatusCompleted)
}
}
func TestWorkExecutor_FailsTaskOnAgentError(t *testing.T) {
deps := newTestDeps()
deps.agent.err = fmt.Errorf("agent crashed")
// Enqueue a build task
deps.queue.mu.Lock()
deps.queue.tasks["task-1"] = &domain.WorkTask{
ID: "task-1",
ProjectID: "project-1",
Type: domain.WorkTaskTypeBuild,
Status: domain.WorkTaskStatusPending,
Spec: map[string]any{"prompt": "Build something"},
MaxRetries: 3,
CreatedAt: time.Now(),
}
deps.queue.mu.Unlock()
executor := NewWorkExecutor(deps.workerSvc, deps.workSvc, deps.buildExec, nil, nil, &WorkExecutorConfig{
WorkerID: "test-worker-3",
PollPeriod: 50 * time.Millisecond,
HeartbeatPeriod: 5 * time.Second,
})
if err := executor.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
// Call tryClaimAndExecute directly for each retry to avoid timing dependency
for i := 0; i < 3; i++ {
executor.tryClaimAndExecute()
}
executor.Stop()
// Task should be permanently failed after all retries.
deps.queue.mu.Lock()
task := deps.queue.tasks["task-1"]
deps.queue.mu.Unlock()
if task.Status != domain.WorkTaskStatusFailed {
t.Errorf("got task status %q, want %q (should be permanently failed after retries)", task.Status, domain.WorkTaskStatusFailed)
}
if task.RetryCount < 3 {
t.Errorf("expected retry_count >= 3, got %d", task.RetryCount)
}
}
func TestWorkExecutor_UnsupportedTaskType(t *testing.T) {
deps := newTestDeps()
// Enqueue a custom task (not build)
deps.queue.mu.Lock()
deps.queue.tasks["task-1"] = &domain.WorkTask{
ID: "task-1",
ProjectID: "project-1",
Type: domain.WorkTaskTypeCustom,
Status: domain.WorkTaskStatusPending,
Spec: map[string]any{"prompt": "Do something custom"},
MaxRetries: 1,
CreatedAt: time.Now(),
}
deps.queue.mu.Unlock()
executor := NewWorkExecutor(deps.workerSvc, deps.workSvc, deps.buildExec, nil, nil, &WorkExecutorConfig{
WorkerID: "test-worker-4",
PollPeriod: 50 * time.Millisecond,
HeartbeatPeriod: 5 * time.Second,
})
if err := executor.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
// Call tryClaimAndExecute directly to avoid timing dependency
executor.tryClaimAndExecute()
executor.Stop()
// Should fail because custom tasks are unsupported
deps.queue.mu.Lock()
task := deps.queue.tasks["task-1"]
deps.queue.mu.Unlock()
// With maxRetries=1 and retryCount=1, it should be permanently failed
if task.Status != domain.WorkTaskStatusFailed {
t.Errorf("got task status %q, want %q", task.Status, domain.WorkTaskStatusFailed)
}
}
// =============================================================================
// BuildExecutor Tests
// =============================================================================
func TestBuildExecutor_Execute(t *testing.T) {
t.Run("successful build", func(t *testing.T) {
agent := &mockCodeAgent{
result: &domain.AgentResult{ExitCode: 0, DurationMs: 500},
}
registry := &mockCodeAgentRegistry{agent: agent}
exec := NewBuildExecutor(registry, nil, nil, nil)
task := &domain.WorkTask{
ID: "task-1",
ProjectID: "project-1",
Type: domain.WorkTaskTypeBuild,
Spec: map[string]any{"prompt": "Build a landing page"},
}
result := exec.Execute(context.Background(), task)
if !result.Success {
t.Errorf("expected success, got error: %s", result.Error)
}
if result.DurationMs < 0 {
t.Errorf("expected non-negative duration, got %d", result.DurationMs)
}
})
t.Run("missing prompt", func(t *testing.T) {
registry := &mockCodeAgentRegistry{agent: &mockCodeAgent{}}
exec := NewBuildExecutor(registry, nil, nil, nil)
task := &domain.WorkTask{
ID: "task-1",
Type: domain.WorkTaskTypeBuild,
Spec: map[string]any{},
}
result := exec.Execute(context.Background(), task)
if result.Success {
t.Error("expected failure for missing prompt")
}
})
t.Run("no agent available", func(t *testing.T) {
registry := &mockCodeAgentRegistry{agent: nil}
exec := NewBuildExecutor(registry, nil, nil, nil)
task := &domain.WorkTask{
ID: "task-1",
Type: domain.WorkTaskTypeBuild,
Spec: map[string]any{"prompt": "Build something"},
}
result := exec.Execute(context.Background(), task)
if result.Success {
t.Error("expected failure when no agent available")
}
})
t.Run("agent execution error", func(t *testing.T) {
agent := &mockCodeAgent{err: fmt.Errorf("connection refused")}
registry := &mockCodeAgentRegistry{agent: agent}
exec := NewBuildExecutor(registry, nil, nil, nil)
task := &domain.WorkTask{
ID: "task-1",
Type: domain.WorkTaskTypeBuild,
Spec: map[string]any{"prompt": "Build something"},
}
result := exec.Execute(context.Background(), task)
if result.Success {
t.Error("expected failure on agent error")
}
if result.Error == "" {
t.Error("expected error message")
}
})
t.Run("agent non-zero exit code", func(t *testing.T) {
agent := &mockCodeAgent{
result: &domain.AgentResult{ExitCode: 1, DurationMs: 500},
}
registry := &mockCodeAgentRegistry{agent: agent}
exec := NewBuildExecutor(registry, nil, nil, nil)
task := &domain.WorkTask{
ID: "task-1",
Type: domain.WorkTaskTypeBuild,
Spec: map[string]any{"prompt": "Build something"},
}
result := exec.Execute(context.Background(), task)
if result.Success {
t.Error("expected failure on non-zero exit code")
}
})
}
func TestBuildExecutor_ParseSpec(t *testing.T) {
exec := NewBuildExecutor(nil, nil, nil, nil)
t.Run("valid spec", func(t *testing.T) {
spec, err := exec.parseSpec(map[string]any{
"prompt": "Build a page",
"template": "astro-landing",
"auto_commit": true,
"auto_push": true,
})
if err != nil {
t.Fatalf("parseSpec() error = %v", err)
}
if spec.Prompt != "Build a page" {
t.Errorf("got prompt %q", spec.Prompt)
}
if !spec.AutoCommit {
t.Error("expected auto_commit = true")
}
if !spec.AutoPush {
t.Error("expected auto_push = true")
}
})
t.Run("missing prompt", func(t *testing.T) {
_, err := exec.parseSpec(map[string]any{
"template": "astro-landing",
})
if err == nil {
t.Error("expected error for missing prompt")
}
})
}
func TestTruncate(t *testing.T) {
tests := []struct {
input string
maxLen int
want string
}{
{"short", 10, "short"},
{"exactly ten", 11, "exactly ten"},
{"this is a long string", 10, "this is..."},
{"abc", 3, "abc"},
{"abcd", 3, "abc"},
}
for _, tt := range tests {
got := truncate(tt.input, tt.maxLen)
if got != tt.want {
t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
}
}
}