- 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>
347 lines
9.2 KiB
Go
347 lines
9.2 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
// =============================================================================
|
|
// WorkExecutor Tests
|
|
// =============================================================================
|
|
|
|
func testLogger() *slog.Logger {
|
|
return slog.Default()
|
|
}
|
|
|
|
func TestWorkExecutor_StartAndStop(t *testing.T) {
|
|
deps := newTestDeps()
|
|
|
|
executor := NewWorkExecutor(deps.workerSvc, deps.workSvc, deps.buildExec, &WorkExecutorConfig{
|
|
WorkerID: "test-worker-1",
|
|
PollPeriod: 100 * time.Millisecond,
|
|
HeartbeatPeriod: 100 * time.Millisecond,
|
|
Logger: testLogger(),
|
|
})
|
|
|
|
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, &WorkExecutorConfig{
|
|
WorkerID: "test-worker-2",
|
|
PollPeriod: 50 * time.Millisecond,
|
|
HeartbeatPeriod: 5 * time.Second,
|
|
Logger: testLogger(),
|
|
})
|
|
|
|
// 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, &WorkExecutorConfig{
|
|
WorkerID: "test-worker-3",
|
|
PollPeriod: 50 * time.Millisecond,
|
|
HeartbeatPeriod: 5 * time.Second,
|
|
Logger: testLogger(),
|
|
})
|
|
|
|
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, &WorkExecutorConfig{
|
|
WorkerID: "test-worker-4",
|
|
PollPeriod: 50 * time.Millisecond,
|
|
HeartbeatPeriod: 5 * time.Second,
|
|
Logger: testLogger(),
|
|
})
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|