- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
352 lines
9.2 KiB
Go
352 lines
9.2 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/auth"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/service"
|
|
)
|
|
|
|
// testAdminAuth is a chi middleware that injects an admin API key into the
|
|
// request context so auth.RequireScope passes in tests.
|
|
func testAdminAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := auth.WithAPIKey(r.Context(), &domain.APIKey{
|
|
Scopes: []domain.Scope{domain.ScopeAdmin},
|
|
})
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// 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) 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,
|
|
},
|
|
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)
|
|
}
|
|
})
|
|
}
|