package handlers import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/service" ) // mockWorkQueue implements port.WorkQueue for testing. type mockWorkQueue struct { tasks map[string]*port.WorkTask err error } func newMockWorkQueue() *mockWorkQueue { return &mockWorkQueue{ tasks: make(map[string]*port.WorkTask), } } func (m *mockWorkQueue) Enqueue(ctx context.Context, task *port.WorkTask) (string, error) { if m.err != nil { return "", m.err } id := "task-123" task.ID = id task.Status = port.WorkTaskStatusPending task.CreatedAt = time.Now() m.tasks[id] = task return id, nil } func (m *mockWorkQueue) Dequeue(ctx context.Context, workerID string) (*port.WorkTask, error) { if m.err != nil { return nil, m.err } for _, task := range m.tasks { if task.Status == port.WorkTaskStatusPending { task.Status = port.WorkTaskStatusRunning task.WorkerID = workerID now := time.Now() task.StartedAt = &now return task, nil } } return nil, nil } func (m *mockWorkQueue) Complete(ctx context.Context, taskID string, result *port.WorkResult) error { if m.err != nil { return m.err } task, ok := m.tasks[taskID] if !ok { return domain.ErrWorkTaskNotFound } task.Status = port.WorkTaskStatusCompleted task.Result = result now := time.Now() task.CompletedAt = &now return nil } func (m *mockWorkQueue) Fail(ctx context.Context, taskID string, errMsg string) error { if m.err != nil { return m.err } task, ok := m.tasks[taskID] if !ok { return domain.ErrWorkTaskNotFound } if task.RetryCount < task.MaxRetries { task.Status = port.WorkTaskStatusPending task.RetryCount++ task.Error = errMsg } else { task.Status = port.WorkTaskStatusFailed task.Error = errMsg now := time.Now() task.CompletedAt = &now } return nil } func (m *mockWorkQueue) Cancel(ctx context.Context, taskID string) error { if m.err != nil { return m.err } task, ok := m.tasks[taskID] if !ok { return domain.ErrWorkTaskNotFound } if task.Status != port.WorkTaskStatusPending { return domain.ErrWorkTaskNotFound } task.Status = port.WorkTaskStatusCancelled now := time.Now() task.CompletedAt = &now return nil } func (m *mockWorkQueue) GetTask(ctx context.Context, taskID string) (*port.WorkTask, error) { if m.err != nil { return nil, m.err } task, ok := m.tasks[taskID] if !ok { return nil, domain.ErrWorkTaskNotFound } return task, nil } func (m *mockWorkQueue) ListByProject(ctx context.Context, projectID string, status *port.WorkTaskStatus, opts port.WorkListOptions) (*port.WorkListResult, error) { if m.err != nil { return nil, m.err } opts.Normalize() var tasks []*port.WorkTask for _, task := range m.tasks { if task.ProjectID == projectID { if status == nil || task.Status == *status { tasks = append(tasks, task) } } } // Apply pagination total := int64(len(tasks)) if opts.Offset >= len(tasks) { tasks = nil } else { end := opts.Offset + opts.Limit if end > len(tasks) { end = len(tasks) } tasks = tasks[opts.Offset:end] } return &port.WorkListResult{ Tasks: tasks, Total: total, Limit: opts.Limit, Offset: opts.Offset, }, nil } func (m *mockWorkQueue) GetStats(ctx context.Context) (*port.WorkQueueStats, error) { if m.err != nil { return nil, m.err } stats := &port.WorkQueueStats{} for _, task := range m.tasks { switch task.Status { case port.WorkTaskStatusPending: stats.Pending++ case port.WorkTaskStatusRunning: stats.Running++ case port.WorkTaskStatusCompleted: stats.Completed++ case port.WorkTaskStatusFailed: stats.Failed++ case port.WorkTaskStatusCancelled: stats.Cancelled++ } } return stats, nil } func (m *mockWorkQueue) CleanupOld(ctx context.Context, olderThan time.Duration) (int64, error) { return 0, m.err } func (m *mockWorkQueue) RequeueStale(ctx context.Context, timeout time.Duration) (int64, error) { return 0, m.err } func TestWorkHandler_Enqueue(t *testing.T) { mockQueue := newMockWorkQueue() workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) handler := NewWorkHandler(workService) router := chi.NewRouter() handler.Mount(router) tests := []struct { name string body EnqueueWorkRequest wantStatus int }{ { name: "valid_build_task", body: EnqueueWorkRequest{ ProjectID: "test-project", TaskType: "build", Spec: map[string]any{ "prompt": "Build a landing page", "template": "nextjs-landing", }, }, wantStatus: http.StatusCreated, }, { name: "valid_test_task", body: EnqueueWorkRequest{ ProjectID: "test-project", TaskType: "test", Spec: map[string]any{ "test_command": "npm test", }, }, wantStatus: http.StatusCreated, }, { name: "valid_deploy_task", body: EnqueueWorkRequest{ ProjectID: "test-project", TaskType: "deploy", Spec: map[string]any{ "image": "registry.example.com/app:latest", "replicas": 2, }, }, wantStatus: http.StatusCreated, }, { name: "valid_custom_task", body: EnqueueWorkRequest{ ProjectID: "test-project", TaskType: "custom", Spec: map[string]any{ "action": "cleanup", }, }, wantStatus: http.StatusCreated, }, { name: "missing_project_id", body: EnqueueWorkRequest{ TaskType: "build", Spec: map[string]any{}, }, wantStatus: http.StatusBadRequest, }, { name: "missing_task_type", body: EnqueueWorkRequest{ ProjectID: "test-project", Spec: map[string]any{}, }, wantStatus: http.StatusBadRequest, }, { name: "invalid_task_type", body: EnqueueWorkRequest{ ProjectID: "test-project", TaskType: "invalid", Spec: map[string]any{}, }, wantStatus: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body, _ := json.Marshal(tt.body) req := httptest.NewRequest(http.MethodPost, "/work/enqueue", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != tt.wantStatus { t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) } }) } } func TestWorkHandler_Dequeue(t *testing.T) { mockQueue := newMockWorkQueue() workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) handler := NewWorkHandler(workService) // Pre-populate a pending task mockQueue.tasks["task-1"] = &port.WorkTask{ ID: "task-1", ProjectID: "test-project", Type: port.WorkTaskTypeBuild, Status: port.WorkTaskStatusPending, CreatedAt: time.Now(), } router := chi.NewRouter() handler.Mount(router) tests := []struct { name string body DequeueWorkRequest wantStatus int wantTask bool }{ { name: "valid_dequeue", body: DequeueWorkRequest{WorkerID: "worker-1"}, wantStatus: http.StatusOK, wantTask: true, }, { name: "missing_worker_id", body: DequeueWorkRequest{}, wantStatus: http.StatusBadRequest, wantTask: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body, _ := json.Marshal(tt.body) req := httptest.NewRequest(http.MethodPost, "/work/dequeue", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != tt.wantStatus { t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) } if tt.wantTask && rec.Code == http.StatusOK { var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatal("response missing data field") } if data["task"] == nil { t.Error("expected task in response") } } }) } } func TestWorkHandler_Complete(t *testing.T) { mockQueue := newMockWorkQueue() workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) handler := NewWorkHandler(workService) // Pre-populate a running task mockQueue.tasks["task-1"] = &port.WorkTask{ ID: "task-1", ProjectID: "test-project", Type: port.WorkTaskTypeBuild, Status: port.WorkTaskStatusRunning, WorkerID: "worker-1", CreatedAt: time.Now(), } router := chi.NewRouter() handler.Mount(router) tests := []struct { name string taskID string body CompleteWorkRequest wantStatus int }{ { name: "valid_complete", taskID: "task-1", body: CompleteWorkRequest{ Output: "Build successful", Artifacts: map[string]string{ "commit_sha": "abc123", }, }, wantStatus: http.StatusOK, }, { name: "task_not_found", taskID: "nonexistent", body: CompleteWorkRequest{Output: "Done"}, wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body, _ := json.Marshal(tt.body) req := httptest.NewRequest(http.MethodPost, "/work/"+tt.taskID+"/complete", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != tt.wantStatus { t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) } }) } } func TestWorkHandler_Fail(t *testing.T) { mockQueue := newMockWorkQueue() workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) handler := NewWorkHandler(workService) // Pre-populate a running task mockQueue.tasks["task-1"] = &port.WorkTask{ ID: "task-1", ProjectID: "test-project", Type: port.WorkTaskTypeBuild, Status: port.WorkTaskStatusRunning, WorkerID: "worker-1", MaxRetries: 3, CreatedAt: time.Now(), } router := chi.NewRouter() handler.Mount(router) tests := []struct { name string taskID string body FailWorkRequest wantStatus int }{ { name: "valid_fail", taskID: "task-1", body: FailWorkRequest{Error: "Build failed: npm error"}, wantStatus: http.StatusOK, }, { name: "missing_error", taskID: "task-1", body: FailWorkRequest{}, wantStatus: http.StatusBadRequest, }, { name: "task_not_found", taskID: "nonexistent", body: FailWorkRequest{Error: "Failed"}, wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body, _ := json.Marshal(tt.body) req := httptest.NewRequest(http.MethodPost, "/work/"+tt.taskID+"/fail", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != tt.wantStatus { t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) } }) } } func TestWorkHandler_Cancel(t *testing.T) { mockQueue := newMockWorkQueue() workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) handler := NewWorkHandler(workService) // Pre-populate tasks mockQueue.tasks["pending-task"] = &port.WorkTask{ ID: "pending-task", ProjectID: "test-project", Type: port.WorkTaskTypeBuild, Status: port.WorkTaskStatusPending, CreatedAt: time.Now(), } mockQueue.tasks["running-task"] = &port.WorkTask{ ID: "running-task", ProjectID: "test-project", Type: port.WorkTaskTypeBuild, Status: port.WorkTaskStatusRunning, CreatedAt: time.Now(), } router := chi.NewRouter() handler.Mount(router) tests := []struct { name string taskID string wantStatus int }{ { name: "cancel_pending_task", taskID: "pending-task", wantStatus: http.StatusOK, }, { name: "cancel_running_task_fails", taskID: "running-task", wantStatus: http.StatusNotFound, // Can only cancel pending tasks }, { name: "task_not_found", taskID: "nonexistent", wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/work/"+tt.taskID+"/cancel", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != tt.wantStatus { t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) } }) } } func TestWorkHandler_GetTask(t *testing.T) { mockQueue := newMockWorkQueue() workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) handler := NewWorkHandler(workService) // Pre-populate a task mockQueue.tasks["task-1"] = &port.WorkTask{ ID: "task-1", ProjectID: "test-project", Type: port.WorkTaskTypeBuild, Status: port.WorkTaskStatusRunning, Spec: map[string]any{ "prompt": "Build it", }, CreatedAt: time.Now(), } router := chi.NewRouter() handler.Mount(router) tests := []struct { name string taskID string wantStatus int }{ { name: "get_existing_task", taskID: "task-1", wantStatus: http.StatusOK, }, { name: "task_not_found", taskID: "nonexistent", wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/work/"+tt.taskID, nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != tt.wantStatus { t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String()) } }) } } func TestWorkHandler_ListByProject(t *testing.T) { mockQueue := newMockWorkQueue() workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) handler := NewWorkHandler(workService) // Pre-populate tasks mockQueue.tasks["task-1"] = &port.WorkTask{ ID: "task-1", ProjectID: "project-a", Type: port.WorkTaskTypeBuild, Status: port.WorkTaskStatusPending, CreatedAt: time.Now(), } mockQueue.tasks["task-2"] = &port.WorkTask{ ID: "task-2", ProjectID: "project-a", Type: port.WorkTaskTypeTest, Status: port.WorkTaskStatusCompleted, CreatedAt: time.Now(), } mockQueue.tasks["task-3"] = &port.WorkTask{ ID: "task-3", ProjectID: "project-b", Type: port.WorkTaskTypeDeploy, Status: port.WorkTaskStatusRunning, CreatedAt: time.Now(), } router := chi.NewRouter() handler.Mount(router) t.Run("list_all_for_project", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } data := resp["data"].(map[string]any) total := int(data["total"].(float64)) if total != 2 { t.Errorf("got %d tasks, want 2", total) } // Verify pagination metadata is present if _, ok := data["limit"]; !ok { t.Error("expected limit in response") } if _, ok := data["offset"]; !ok { t.Error("expected offset in response") } }) t.Run("list_with_status_filter", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?status=pending", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("got status %d, want %d", rec.Code, http.StatusOK) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } data := resp["data"].(map[string]any) total := int(data["total"].(float64)) if total != 1 { t.Errorf("got %d tasks, want 1", total) } }) t.Run("list_with_pagination", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?limit=1&offset=0", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("got status %d, want %d", rec.Code, http.StatusOK) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } data := resp["data"].(map[string]any) // Total should reflect all matching tasks total := int(data["total"].(float64)) if total != 2 { t.Errorf("got total=%d, want 2", total) } // But tasks returned should be limited tasks := data["tasks"].([]any) if len(tasks) != 1 { t.Errorf("got %d tasks returned, want 1", len(tasks)) } // Verify limit/offset are reflected if int(data["limit"].(float64)) != 1 { t.Errorf("got limit=%v, want 1", data["limit"]) } if int(data["offset"].(float64)) != 0 { t.Errorf("got offset=%v, want 0", data["offset"]) } }) t.Run("invalid_limit", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?limit=invalid", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest) } }) t.Run("invalid_offset", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?offset=invalid", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest) } }) t.Run("invalid_status_filter", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?status=invalid", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest) } }) } func TestWorkHandler_Stats(t *testing.T) { mockQueue := newMockWorkQueue() workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{}) handler := NewWorkHandler(workService) // Pre-populate tasks with various statuses mockQueue.tasks["task-1"] = &port.WorkTask{ID: "task-1", Status: port.WorkTaskStatusPending} mockQueue.tasks["task-2"] = &port.WorkTask{ID: "task-2", Status: port.WorkTaskStatusPending} mockQueue.tasks["task-3"] = &port.WorkTask{ID: "task-3", Status: port.WorkTaskStatusRunning} mockQueue.tasks["task-4"] = &port.WorkTask{ID: "task-4", Status: port.WorkTaskStatusCompleted} mockQueue.tasks["task-5"] = &port.WorkTask{ID: "task-5", Status: port.WorkTaskStatusFailed} router := chi.NewRouter() handler.Mount(router) req := httptest.NewRequest(http.MethodGet, "/work/stats", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } data := resp["data"].(map[string]any) if int(data["pending"].(float64)) != 2 { t.Errorf("got pending=%v, want 2", data["pending"]) } if int(data["running"].(float64)) != 1 { t.Errorf("got running=%v, want 1", data["running"]) } if int(data["completed"].(float64)) != 1 { t.Errorf("got completed=%v, want 1", data["completed"]) } if int(data["failed"].(float64)) != 1 { t.Errorf("got failed=%v, want 1", data["failed"]) } }