- Add auth.RequireScope() to all handler routes for proper authorization - Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator) - Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog) - Add artifact_test.go for SDLC artifact coverage - Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w - Fix error wrapping to use %w instead of %v throughout codebase - Improve CLI merge command with conflict detection and resolution - Fix handler tests to include auth middleware for RequireScope - Add cookbook tree runner scripts for automated testing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
387 lines
10 KiB
Go
387 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"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"
|
|
)
|
|
|
|
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"] = &domain.WorkTask{
|
|
ID: "task-1",
|
|
ProjectID: "test-project",
|
|
Type: domain.WorkTaskTypeBuild,
|
|
Status: domain.WorkTaskStatusRunning,
|
|
WorkerID: "worker-1",
|
|
MaxRetries: 3,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
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"] = &domain.WorkTask{
|
|
ID: "pending-task",
|
|
ProjectID: "test-project",
|
|
Type: domain.WorkTaskTypeBuild,
|
|
Status: domain.WorkTaskStatusPending,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
mockQueue.tasks["running-task"] = &domain.WorkTask{
|
|
ID: "running-task",
|
|
ProjectID: "test-project",
|
|
Type: domain.WorkTaskTypeBuild,
|
|
Status: domain.WorkTaskStatusRunning,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
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"] = &domain.WorkTask{
|
|
ID: "task-1",
|
|
ProjectID: "test-project",
|
|
Type: domain.WorkTaskTypeBuild,
|
|
Status: domain.WorkTaskStatusRunning,
|
|
Spec: map[string]any{
|
|
"prompt": "Build it",
|
|
},
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
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"] = &domain.WorkTask{
|
|
ID: "task-1",
|
|
ProjectID: "project-a",
|
|
Type: domain.WorkTaskTypeBuild,
|
|
Status: domain.WorkTaskStatusPending,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
mockQueue.tasks["task-2"] = &domain.WorkTask{
|
|
ID: "task-2",
|
|
ProjectID: "project-a",
|
|
Type: domain.WorkTaskTypeTest,
|
|
Status: domain.WorkTaskStatusCompleted,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
mockQueue.tasks["task-3"] = &domain.WorkTask{
|
|
ID: "task-3",
|
|
ProjectID: "project-b",
|
|
Type: domain.WorkTaskTypeDeploy,
|
|
Status: domain.WorkTaskStatusRunning,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
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"] = &domain.WorkTask{ID: "task-1", Status: domain.WorkTaskStatusPending}
|
|
mockQueue.tasks["task-2"] = &domain.WorkTask{ID: "task-2", Status: domain.WorkTaskStatusPending}
|
|
mockQueue.tasks["task-3"] = &domain.WorkTask{ID: "task-3", Status: domain.WorkTaskStatusRunning}
|
|
mockQueue.tasks["task-4"] = &domain.WorkTask{ID: "task-4", Status: domain.WorkTaskStatusCompleted}
|
|
mockQueue.tasks["task-5"] = &domain.WorkTask{ID: "task-5", Status: domain.WorkTaskStatusFailed}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(testAdminAuth)
|
|
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"])
|
|
}
|
|
}
|