Critical fix: WorkersHandler was missing workService dependency, causing 500 errors when workers tried to fail tasks. This caused tasks to get stuck in "running" state permanently. Also adds: - /work/tasks endpoint for debugging all tasks across projects - List method to WorkQueue interface for admin views - HTTP client tests for api_client.go and claudebox/client.go (48 tests) - Split work.go DTOs into work_dto.go to stay under 500 lines Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
274 lines
7.3 KiB
Go
274 lines
7.3 KiB
Go
package sdlc
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
// mockWorkQueue implements port.WorkQueue for testing.
|
|
type mockWorkQueue struct {
|
|
tasks map[string]*domain.WorkTask
|
|
err error
|
|
}
|
|
|
|
func newMockWorkQueue() *mockWorkQueue {
|
|
return &mockWorkQueue{tasks: make(map[string]*domain.WorkTask)}
|
|
}
|
|
|
|
func (m *mockWorkQueue) Enqueue(ctx context.Context, task *domain.WorkTask) (string, error) {
|
|
if m.err != nil {
|
|
return "", m.err
|
|
}
|
|
id := fmt.Sprintf("task-%d", len(m.tasks)+1)
|
|
task.ID = id
|
|
task.Status = domain.WorkTaskStatusPending
|
|
task.CreatedAt = time.Now()
|
|
m.tasks[id] = task
|
|
return id, nil
|
|
}
|
|
|
|
func (m *mockWorkQueue) Dequeue(ctx context.Context, workerID string) (*domain.WorkTask, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockWorkQueue) Complete(ctx context.Context, taskID string, result *domain.WorkResult) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
task, ok := m.tasks[taskID]
|
|
if !ok {
|
|
return domain.ErrWorkTaskNotFound
|
|
}
|
|
task.Status = domain.WorkTaskStatusCompleted
|
|
task.Result = result
|
|
return nil
|
|
}
|
|
|
|
func (m *mockWorkQueue) Fail(ctx context.Context, taskID string, errMsg string) error {
|
|
return m.FailWithCode(ctx, taskID, errMsg, domain.WorkErrorCodeNone)
|
|
}
|
|
|
|
func (m *mockWorkQueue) FailWithCode(ctx context.Context, taskID string, errMsg string, code domain.WorkErrorCode) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
task, ok := m.tasks[taskID]
|
|
if !ok {
|
|
return domain.ErrWorkTaskNotFound
|
|
}
|
|
task.Status = domain.WorkTaskStatusFailed
|
|
task.Error = errMsg
|
|
task.ErrorCode = code
|
|
return nil
|
|
}
|
|
|
|
func (m *mockWorkQueue) Cancel(ctx context.Context, taskID string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockWorkQueue) GetTask(ctx context.Context, taskID string) (*domain.WorkTask, error) {
|
|
task, ok := m.tasks[taskID]
|
|
if !ok {
|
|
return nil, domain.ErrWorkTaskNotFound
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
func (m *mockWorkQueue) List(ctx context.Context, status *domain.WorkTaskStatus, opts domain.WorkListOptions) (*domain.WorkListResult, error) {
|
|
return &domain.WorkListResult{}, nil
|
|
}
|
|
|
|
func (m *mockWorkQueue) ListByProject(ctx context.Context, projectID string, status *domain.WorkTaskStatus, opts domain.WorkListOptions) (*domain.WorkListResult, error) {
|
|
return &domain.WorkListResult{}, nil
|
|
}
|
|
|
|
func (m *mockWorkQueue) GetStats(ctx context.Context) (*domain.WorkQueueStats, error) {
|
|
return &domain.WorkQueueStats{}, nil
|
|
}
|
|
|
|
func (m *mockWorkQueue) CleanupOld(ctx context.Context, olderThan time.Duration) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (m *mockWorkQueue) RequeueStale(ctx context.Context, timeout time.Duration) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (m *mockWorkQueue) RequeueStaleWithIDs(ctx context.Context, timeout time.Duration) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestWorkerSDLCExecutor_EnqueueTask(t *testing.T) {
|
|
queue := newMockWorkQueue()
|
|
exec := NewWorkerSDLCExecutor(WorkerSDLCExecutorConfig{
|
|
WorkQueue: queue,
|
|
DB: nil, // No DB for this test
|
|
Timeout: 2 * time.Second,
|
|
})
|
|
|
|
// Test that enqueue builds the correct spec
|
|
spec := domain.SDLCTaskSpec{
|
|
Command: "feature-create",
|
|
Args: []string{"auth-flow", "--title", "Auth Flow"},
|
|
GitCloneURL: "https://git.example.com/owner/repo.git",
|
|
AutoCommit: true,
|
|
AutoPush: true,
|
|
}
|
|
|
|
// Start the completion goroutine before enqueueing
|
|
// Use a channel to synchronize
|
|
done := make(chan struct{})
|
|
go func() {
|
|
// Wait a bit for the task to be enqueued
|
|
time.Sleep(100 * time.Millisecond)
|
|
for i := 0; i < 10; i++ {
|
|
if len(queue.tasks) > 0 {
|
|
break
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
for _, task := range queue.tasks {
|
|
task.Status = domain.WorkTaskStatusCompleted
|
|
featureJSON, _ := json.Marshal(map[string]string{
|
|
"slug": "auth-flow",
|
|
"title": "Auth Flow",
|
|
})
|
|
task.Result = &domain.WorkResult{Output: string(featureJSON)}
|
|
}
|
|
close(done)
|
|
}()
|
|
|
|
output, err := exec.enqueueAndWait(context.Background(), "project-1", spec)
|
|
<-done // Wait for completion goroutine
|
|
if err != nil {
|
|
t.Fatalf("enqueueAndWait() error = %v", err)
|
|
}
|
|
if output == "" {
|
|
t.Error("expected output")
|
|
}
|
|
|
|
// Verify task was enqueued with correct spec
|
|
if len(queue.tasks) != 1 {
|
|
t.Fatalf("expected 1 task, got %d", len(queue.tasks))
|
|
}
|
|
for _, task := range queue.tasks {
|
|
if task.Type != domain.WorkTaskTypeSDLC {
|
|
t.Errorf("got task type %q, want %q", task.Type, domain.WorkTaskTypeSDLC)
|
|
}
|
|
if cmd, _ := task.Spec["command"].(string); cmd != "feature-create" {
|
|
t.Errorf("got command %q, want %q", cmd, "feature-create")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWorkerSDLCExecutor_Timeout(t *testing.T) {
|
|
queue := newMockWorkQueue()
|
|
exec := NewWorkerSDLCExecutor(WorkerSDLCExecutorConfig{
|
|
WorkQueue: queue,
|
|
DB: nil,
|
|
Timeout: 100 * time.Millisecond, // Short timeout
|
|
})
|
|
|
|
spec := domain.SDLCTaskSpec{
|
|
Command: "feature-create",
|
|
Args: []string{"auth-flow"},
|
|
GitCloneURL: "https://git.example.com/owner/repo.git",
|
|
}
|
|
|
|
// Don't complete the task - it should timeout
|
|
_, err := exec.enqueueAndWait(context.Background(), "project-1", spec)
|
|
if err == nil {
|
|
t.Error("expected timeout error")
|
|
}
|
|
}
|
|
|
|
func TestWorkerSDLCExecutor_TaskFailed(t *testing.T) {
|
|
queue := newMockWorkQueue()
|
|
exec := NewWorkerSDLCExecutor(WorkerSDLCExecutorConfig{
|
|
WorkQueue: queue,
|
|
DB: nil,
|
|
Timeout: 500 * time.Millisecond,
|
|
})
|
|
|
|
spec := domain.SDLCTaskSpec{
|
|
Command: "feature-create",
|
|
Args: []string{"auth-flow"},
|
|
GitCloneURL: "https://git.example.com/owner/repo.git",
|
|
}
|
|
|
|
// Simulate task failure
|
|
go func() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
for _, task := range queue.tasks {
|
|
task.Status = domain.WorkTaskStatusFailed
|
|
task.Error = "sdlc command failed: feature already exists"
|
|
}
|
|
}()
|
|
|
|
_, err := exec.enqueueAndWait(context.Background(), "project-1", spec)
|
|
if err == nil {
|
|
t.Error("expected error for failed task")
|
|
}
|
|
}
|
|
|
|
func TestWorkerSDLCExecutor_ContextCancelled(t *testing.T) {
|
|
queue := newMockWorkQueue()
|
|
exec := NewWorkerSDLCExecutor(WorkerSDLCExecutorConfig{
|
|
WorkQueue: queue,
|
|
DB: nil,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
spec := domain.SDLCTaskSpec{
|
|
Command: "feature-create",
|
|
Args: []string{"auth-flow"},
|
|
GitCloneURL: "https://git.example.com/owner/repo.git",
|
|
}
|
|
|
|
_, err := exec.enqueueAndWait(ctx, "project-1", spec)
|
|
if err == nil {
|
|
t.Error("expected context cancelled error")
|
|
}
|
|
}
|
|
|
|
func TestWorkerSDLCExecutor_NoGitURL(t *testing.T) {
|
|
queue := newMockWorkQueue()
|
|
exec := NewWorkerSDLCExecutor(WorkerSDLCExecutorConfig{
|
|
WorkQueue: queue,
|
|
DB: nil, // No DB - can't get git URL
|
|
Timeout: 500 * time.Millisecond,
|
|
})
|
|
|
|
// This should fail because we can't get the git URL without a database
|
|
_, err := exec.GetState(context.Background(), "project-1")
|
|
if err == nil {
|
|
t.Error("expected error when DB is nil")
|
|
}
|
|
}
|
|
|
|
func TestWorkerSDLCExecutor_InterfaceCompliance(t *testing.T) {
|
|
// Verify that WorkerSDLCExecutor implements port.SDLCExecutor at compile time
|
|
// This is already done in the source file, but we test it here too
|
|
exec := NewWorkerSDLCExecutor(WorkerSDLCExecutorConfig{
|
|
WorkQueue: newMockWorkQueue(),
|
|
DB: (*sql.DB)(nil),
|
|
})
|
|
|
|
// Just ensure the executor exists and has the right methods
|
|
if exec == nil {
|
|
t.Error("expected non-nil executor")
|
|
}
|
|
}
|