rdev/internal/handlers/builds_test.go
jordan 56e3f83955 feat: add auth scopes, OpenAPI docs, SDLC guides, and code quality improvements
- 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>
2026-02-02 13:55:50 -07:00

354 lines
9.1 KiB
Go

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"
)
// mockBuildAudit implements port.BuildAudit for testing.
type mockBuildAudit struct {
entries map[string]*domain.BuildAuditEntry
err error
}
func newMockBuildAudit() *mockBuildAudit {
return &mockBuildAudit{
entries: make(map[string]*domain.BuildAuditEntry),
}
}
func (m *mockBuildAudit) Record(_ context.Context, entry *domain.BuildAuditEntry) error {
if m.err != nil {
return m.err
}
m.entries[entry.TaskID] = entry
return nil
}
func (m *mockBuildAudit) Update(_ context.Context, taskID string, result *domain.BuildResult) error {
if m.err != nil {
return m.err
}
entry, ok := m.entries[taskID]
if !ok {
return domain.ErrBuildNotFound
}
entry.Result = result
if result.Success {
entry.Status = domain.BuildStatusCompleted
} else {
entry.Status = domain.BuildStatusFailed
}
now := time.Now()
entry.CompletedAt = &now
return nil
}
func (m *mockBuildAudit) UpdateStatus(_ context.Context, taskID string, status domain.BuildStatus, workerID string) error {
if m.err != nil {
return m.err
}
entry, ok := m.entries[taskID]
if !ok {
return domain.ErrBuildNotFound
}
entry.Status = status
entry.WorkerID = workerID
return nil
}
func (m *mockBuildAudit) Get(_ context.Context, taskID string) (*domain.BuildAuditEntry, error) {
if m.err != nil {
return nil, m.err
}
entry, ok := m.entries[taskID]
if !ok {
return nil, domain.ErrBuildNotFound
}
return entry, nil
}
func (m *mockBuildAudit) List(_ context.Context, filter port.BuildAuditFilter) ([]*domain.BuildAuditEntry, error) {
if m.err != nil {
return nil, m.err
}
var result []*domain.BuildAuditEntry
for _, entry := range m.entries {
if filter.ProjectID != "" && entry.ProjectID != filter.ProjectID {
continue
}
result = append(result, entry)
if filter.Limit > 0 && len(result) >= filter.Limit {
break
}
}
return result, nil
}
func TestBuildsHandler_StartBuild(t *testing.T) {
queue := newMockWorkQueue()
audit := newMockBuildAudit()
buildService := service.NewBuildService(queue, audit, nil)
handler := NewBuildsHandler(buildService)
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
tests := []struct {
name string
projectID string
body StartBuildRequest
wantStatus int
}{
{
name: "valid_build",
projectID: "my-project",
body: StartBuildRequest{
Prompt: "Build a landing page with Next.js",
Template: "nextjs-landing",
AutoCommit: true,
AutoPush: true,
GitCloneURL: "https://git.example.com/org/my-project.git",
},
wantStatus: http.StatusCreated,
},
{
name: "missing_prompt",
projectID: "my-project",
body: StartBuildRequest{
Template: "nextjs-landing",
},
wantStatus: http.StatusBadRequest,
},
{
name: "minimal_build",
projectID: "test-project",
body: StartBuildRequest{
Prompt: "Add a footer component",
},
wantStatus: http.StatusCreated,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest(http.MethodPost, "/projects/"+tt.projectID+"/builds", 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.wantStatus == http.StatusCreated {
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("expected data to be map, got %T", resp["data"])
}
if data["task_id"] == nil || data["task_id"] == "" {
t.Error("expected task_id in response")
}
if data["project_id"] != tt.projectID {
t.Errorf("got project_id=%v, want %s", data["project_id"], tt.projectID)
}
if data["status"] != "pending" {
t.Errorf("got status=%v, want pending", data["status"])
}
}
})
}
}
func TestBuildsHandler_GetBuild(t *testing.T) {
queue := newMockWorkQueue()
audit := newMockBuildAudit()
buildService := service.NewBuildService(queue, audit, nil)
handler := NewBuildsHandler(buildService)
// Pre-populate an audit entry
audit.entries["task-1"] = &domain.BuildAuditEntry{
TaskID: "task-1",
ProjectID: "my-project",
WorkerID: "worker-1",
Spec: domain.BuildSpec{
Prompt: "Build landing page",
Template: "nextjs-landing",
},
Status: domain.BuildStatusCompleted,
StartedAt: time.Now().Add(-5 * time.Minute),
Result: &domain.BuildResult{
Success: true,
CommitSHA: "abc123",
DurationMs: 30000,
},
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
tests := []struct {
name string
taskID string
wantStatus int
}{
{
name: "existing_build",
taskID: "task-1",
wantStatus: http.StatusOK,
},
{
name: "not_found",
taskID: "nonexistent",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/builds/"+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())
}
if tt.wantStatus == 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, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("expected data to be map, got %T", resp["data"])
}
if data["task_id"] != "task-1" {
t.Errorf("got task_id=%v, want task-1", data["task_id"])
}
if data["status"] != "completed" {
t.Errorf("got status=%v, want completed", data["status"])
}
}
})
}
}
func TestBuildsHandler_ListBuilds(t *testing.T) {
queue := newMockWorkQueue()
audit := newMockBuildAudit()
buildService := service.NewBuildService(queue, audit, nil)
handler := NewBuildsHandler(buildService)
// Pre-populate audit entries
audit.entries["task-1"] = &domain.BuildAuditEntry{
TaskID: "task-1",
ProjectID: "project-a",
Status: domain.BuildStatusCompleted,
Spec: domain.BuildSpec{Prompt: "Build page"},
StartedAt: time.Now().Add(-10 * time.Minute),
}
audit.entries["task-2"] = &domain.BuildAuditEntry{
TaskID: "task-2",
ProjectID: "project-a",
Status: domain.BuildStatusRunning,
Spec: domain.BuildSpec{Prompt: "Add footer"},
StartedAt: time.Now().Add(-5 * time.Minute),
}
audit.entries["task-3"] = &domain.BuildAuditEntry{
TaskID: "task-3",
ProjectID: "project-b",
Status: domain.BuildStatusPending,
Spec: domain.BuildSpec{Prompt: "Other project"},
StartedAt: time.Now(),
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
t.Run("list_builds_for_project", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/projects/project-a/builds", 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, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("expected data to be map, got %T", resp["data"])
}
totalF, ok := data["total"].(float64)
if !ok {
t.Fatalf("expected total to be float64, got %T", data["total"])
}
if int(totalF) != 2 {
t.Errorf("got total=%d, want 2", int(totalF))
}
if data["project_id"] != "project-a" {
t.Errorf("got project_id=%v, want project-a", data["project_id"])
}
})
t.Run("list_with_limit", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/projects/project-a/builds?limit=1", 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, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("expected data to be map, got %T", resp["data"])
}
totalF, ok := data["total"].(float64)
if !ok {
t.Fatalf("expected total to be float64, got %T", data["total"])
}
if int(totalF) != 1 {
t.Errorf("got total=%d, want 1 (limited)", int(totalF))
}
})
t.Run("invalid_limit", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/projects/project-a/builds?limit=abc", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest)
}
})
}