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/service" ) // 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 := "task-123" 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) { if m.err != nil { return nil, m.err } for _, task := range m.tasks { if task.Status == domain.WorkTaskStatusPending { task.Status = domain.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 *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 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 = domain.WorkTaskStatusPending task.RetryCount++ task.Error = errMsg } else { task.Status = domain.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 != domain.WorkTaskStatusPending { return domain.ErrWorkTaskNotFound } task.Status = domain.WorkTaskStatusCancelled now := time.Now() task.CompletedAt = &now return nil } func (m *mockWorkQueue) GetTask(ctx context.Context, taskID string) (*domain.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 *domain.WorkTaskStatus, opts domain.WorkListOptions) (*domain.WorkListResult, error) { if m.err != nil { return nil, m.err } opts.Normalize() var tasks []*domain.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 &domain.WorkListResult{ Tasks: tasks, Total: total, Limit: opts.Limit, Offset: opts.Offset, }, nil } func (m *mockWorkQueue) GetStats(ctx context.Context) (*domain.WorkQueueStats, error) { if m.err != nil { return nil, m.err } stats := &domain.WorkQueueStats{} for _, task := range m.tasks { switch task.Status { case domain.WorkTaskStatusPending: stats.Pending++ case domain.WorkTaskStatusRunning: stats.Running++ case domain.WorkTaskStatusCompleted: stats.Completed++ case domain.WorkTaskStatusFailed: stats.Failed++ case domain.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"] = &domain.WorkTask{ ID: "task-1", ProjectID: "test-project", Type: domain.WorkTaskTypeBuild, Status: domain.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"] = &domain.WorkTask{ ID: "task-1", ProjectID: "test-project", Type: domain.WorkTaskTypeBuild, Status: domain.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()) } }) } }